Why bake
- Ray traced renders (Cycles) simulate light bounces, indirect lighting, and soft shadows. WebGL cannot afford that per frame, so real-time renders never match a Blender render
- Baking saves the ray traced result into a texture applied to the geometry in Three.js. No lights, no real-time shadows, just a texture on meshes, so it looks like the offline render and runs fast
- Costs: baking is slow to produce, textures add load time and a GPU upload freeze at startup, and lights are frozen. Any lighting change means re-baking in Blender
- Mixing baked and dynamic materials is possible but keeping them visually homogeneous is hard
- The pipeline: model, optimize geometry, UV unwrap everything, bake to texture(s), export model + texture, import in Three.js and apply the texture with a lightless material
Blender essentials
- Blender's up axis is
Z. Three.js usesY. The glTF exporter's+Y Upoption handles the conversion, never rotate the scene to compensate - 1 Blender unit exports as 1 Three.js unit. Decide a unit scale up front, the grid squares are 1 unit each
- Shortcuts are hover-area sensitive and mode sensitive: the cursor must be over the
3D Viewportfor viewport shortcuts. On Mac,CTRLmeansCTRL, notCMD - Core viewport:
MIDDLE MOUSEorbit,SHIFT + MIDDLE MOUSEtruck/pedestal,WHEELdolly (gets stuck at the view-point, useSHIFT + CTRL + MIDDLE MOUSEto truly move forward),NUMPAD 1/3/7axis views,NUMPAD 0camera view,SHIFT + Creset,NUMPAD ,focus selection,NUMPAD /isolate selection - Core editing:
SHIFT + Aadd object,Xdelete,G/R/Smove/rotate/scale (thenX/Y/Zto constrain axis),TABtogglesEdit Mode,1/2/3switch vertex/edge/face select,SHIFT + Dduplicate,ALT + Dlinked duplicate,Aselect all,F3search any action by name F9reopens the creation parameters popup (vertex count, size) right after adding a primitive; it is gone once you edit the geometry- Rendering engines:
Eeveeis real-time and roughly matches what WebGL can do;Cyclesis the ray tracer you bake with. Baking requires Cycles, theBakepanel does not exist under Eevee Principled BSDFis PBR and maps toMeshStandardMaterialon export.Emissionsurfaces are for glowing objects. Change onlyBase ColorandRoughnessfor a flat stylized look- If the interface is wrecked, close and reopen Blender.
File > Defaults > Save Startup Filepersists a layout you like
Modeling recipes
- Buns/pebbles: cube,
Subdivision Surfacemodifier, raise subdivisions, right clickShade Smooth, then inEdit Modeadd loop cuts (CTRL + R) near top and bottom edges to control roundness. Modifiers are non-destructive, the base cube stays editable - Melted flat shapes (cheese): plane,
Subdividewith ~10 cuts, enableProportional Editing(O, falloffSharp,WHEELadjusts radius) and drag corner vertices down, add aSolidifymodifier for thickness - Sharp-plus-smooth surfaces:
Shade Smoothalone smears hard edges. UseShade Auto Smooth(Blender 4+) orObject Data Properties > Normals > Auto Smooth(pre-4) so flat areas stay crisp - Low poly cylinders: create with ~10 vertices in the creation popup.
Triangle Fancap fill lets you delete wedge faces to make split logs, then select 4 vertices and pressFto fill missing faces - Rocks: cube, then repeated
Bisectcuts (find it viaF3) withClear Outer+Fill. Do not hand-tweak vertices afterward or you break face flatness. Keep the origin at floor level so scaling stays grounded. Reuse the same few rocks everywhere, nobody notices - Insets and extrudes (lamp recesses): select faces,
Ifor inset, click immediately, then setIndividual+ thickness in the operator panel. For per-face extrusion setTransform Pivot PointtoIndividual Originsfirst,E, orientationNormal. Separate faces into their own object withP > Selectionwhen they need a different material later - Repeated identical objects (portal bricks, pole lights): duplicate with
ALT + D(linked duplicate) so editing one edits all. Rotate during the duplicate move, thenF3 > Repeat Laststamps out a circle of bricks - Organize with
Collections(Mto move selection into one). They are folders, not Three.js groups: they do not transform children. Keep the scene graph flat, deep parenting breaks the later import code. Make helper collections unselectable via theOutlinerfilter arrows - Share one material across objects: select targets,
SHIFT + clickthe object that has the material last (active object),CTRL + L > Materials - Emissive objects use an
Emissionsurface with highStrength(lamps ~80, portal ~40). One largeArea Lightgives diffuse ambient lighting and soft shadows
Reference renders in Blender
Do a real Cycles render before baking: the bake is supposed to reproduce it, and it is your ground truth for colors and lighting.
- A camera is required to render (
F12).NUMPAD 0enters camera view; move the camera from a second viewport until the scene fills the frame Render Properties > Samplingdrives quality vs time: 128 is decent with visible noise, 256 is mostly clean, 512 is safely clean, 1024 only if the machine can take it. Keep viewport samples low (~32) for interactivity- Check
Denoisefor final presentation renders to remove sampling noise (but never for bakes, see below) - Output resolution lives in
Output Properties(1920x1080 at 100% for a shareable render) - After the render finishes,
ALT + Sover the render window saves the image - Iterate lighting here: white strong lights first to judge material colors neutrally, then tint the
Area Light(warm orange for sunrise, blue plus dimmer emissions for night). Every lighting decision is final once baked
Optimize before unwrapping
Do all of this before any UV work, removing faces after unwrapping invalidates the layout.
- Delete every hidden face: bottoms of trunks resting on the floor, faces pressed against other faces (stair backs, brick faces inside stairs). Toggle
Wireframe/Solidshading to see them - Unlink all duplicates: select everything,
F3 > Make Single User > Object & Data. Linked duplicates share geometry, so both copies would unwrap to the same UV coordinates and bake twice into the same texel region - Fix face orientation: overlay menu, check
Face Orientation. Blue is correct, red is flipped. Flipped normals bake black and are invisible in Three.js (backface culling). Select red faces inEdit Mode,F3 > flip > Mesh > Normals > Flip - Apply object scales: select all in
Object Mode,CTRL + A > Scale. Automatic unwrapping sizes islands from geometry, not object scale, so an object scaled inObject Modegets a wrongly sized island (a shrunken axe would still claim half the texture) - Move objects that will get their own Three.js material (emissions: lamps, portal disc) into their own collection and make it unselectable. Baking them wastes texture space; they will be flat colors or shaders in Three.js
UV unwrapping
- Goal: unfold every baked geometry into one shared 0-1 square. One texture is enough for a small scene; split into multiple textures only for large scenes needing more resolution
- Change an area to
UV Editor. UV shortcuts mirror the viewport:Aselect all,Gmove,Sscale, while hovering the editor - Boxes need seams before unwrap or the unfold is garbage: in edge select, choose the cut edges,
U > Mark Seam(red), then select the island (CTRL + Lselects connected faces) andU > Unwrap. Wrong seams are removed withU > Clear Seam. The test of a good unwrap is undistorted faces - Increase the unwrap
Margin(operator panel, e.g. 0.05 to 0.15) so islands do not touch, baking bleeds past island edges - Save effort on repeated objects: unwrap one fence, delete the others, duplicate the unwrapped one into their places, then select all copies and unwrap together
- Organic shapes (rocks):
U > Smart UV Projectwith an island margin (~0.025). Automatic unwraps are decent but produce more islands and seams than manual work; manual unwrapping keeps groups compact and separable - Check proportions with
Display Stretch > Areain the UV Editor overlay: scale island groups until colors match, so no object gets more or less texel density than it needs. Deliberately upscale surfaces seen up close (the floor) - Packing tips: arrange groups into a rough square first, then scale the whole thing into the UV square; keep spacing between islands; keep leftover room for forgotten objects; use a group of many small islands (rocks) as filler
Baking
- Create the target image in the
UV Editor:+ New, namebaked,4096 x 4096(power of 2; 8192 is not safe in every browser), white base color (mip mapping bleeds the background color at edges in Three.js, white reads as a reflection), no alpha,Generated Type: Blank,32-bit Floatchecked, not tiled 32-bit Floatmakes the bake an HDR intermediate. Bake into HDR to keep full precision, tone map later. Save it asRadiance HDR(Image > Save As,baked.hdr) next to the.blend- Tell each material where to bake: in the
Shader Editor, add aTexture > Image Texturenode, pick thebakedimage, leave it unconnected, and make sure it is the active (outlined) node. The active image texture node is the bake target. Keep itsColor Spacelinear, this is the HDR intermediate, not the final texture - Bake settings (
Render Properties > Bake, Cycles only):Bake Type: Combined;View From: Active Camera(affects reflections);Margin: 16 px, typeExtend(cleaner edges in WebGL than adjacent-face pixels); uncheckClear Imageso successive per-object bakes accumulate into one texture - Sampling: 128 samples is enough for a 4096 bake. Turn
DenoiseOFF for baking (it is applied later in the compositor and can artifact during bakes) - Select the object(s), confirm the texture node is active in every involved material, hit
Bake. Objects sharing a material already share the node. You can bake several objects at once if all their materials have the node set - If the bake finishes but the image looks unchanged, it is a display bug: save the image (
ALT + Sover the UV Editor) and hit refresh in the side panel. Save the image after every bake, it is a separate file from the.blend - Troubleshooting: overlapping bakes mean overlapping islands, separate them and re-bake those parts on top; unusually black islands mean flipped normals, flip and re-bake
From HDR bake to final JPEG
Raw bakes look burned out and noisy: baking bypasses both the color management (Filmic/AgX view transform) and the Denoise render option. Fix all of it in the Compositor:
- Open a
Compositorarea, checkUse Nodes - Add an
Imagenode pointing atbaked, aDenoisenode (defaults), and wireImage > Denoise > Composite - Mute the default
Render Layersnode withMso the scene itself is not rendered - The view transform (
FilmicorAgX, inRender Properties > Color Management) is applied automatically during this composite render - Set the output resolution to
4096 x 4096inOutput Properties, pressF12, thenALT + Sover the render window - Save as
JPEG, ~75% quality,baked.jpg. JPEG is fine because no alpha is needed; sRGB encoding comes with it
glTF export
- Select only what should ship (everything except camera, lights, and helper collections),
File > Export > glTF 2.0 - Settings: format
glTF Binary(.glb);Remember Export Settings; includeSelected Objectsonly;+Y Up;Apply Modifiers(otherwise a subdivided cube exports as the raw cube); geometry data limited to what you use. For a baked scene exportUVsonly, no normals, no materials, the texture carries everything;Compressionon (Draco) - Draco shrinks the file substantially but Three.js must ship the decoder. For tiny uncompressed models the decoder can cost more than it saves; decide per project
- Object names survive export. In Blender, rename each object needing special treatment in Three.js (
poleLightA,poleLightB,portalLight,baked). Blender names are unique per object, so name-based lookup is reliable
Importing into Three.js
const dracoLoader = new DRACOLoader()
dracoLoader.setDecoderPath('draco/')
const gltfLoader = new GLTFLoader()
gltfLoader.setDRACOLoader(dracoLoader)
const bakedTexture = textureLoader.load('baked.jpg')
bakedTexture.flipY = false
bakedTexture.colorSpace = THREE.SRGBColorSpace
const bakedMaterial = new THREE.MeshBasicMaterial({ map: bakedTexture })
- Exported PBR materials import as
MeshStandardMaterial, which needs lights, so the scene appears as black silhouettes. Replace withMeshBasicMaterialcarrying the baked map, lighting is already in the texture flipY = falseis mandatory: glTF and Three.js disagree on the texture Y direction, without it the texture is vertically mirrored and lands on the wrong facescolorSpace = THREE.SRGBColorSpaceon the baked texture, or colors look washed out compared to the Blender render- Find named objects and give emissives their own lightless materials:
gltfLoader.load('portal.glb', (gltf) => {
scene.add(gltf.scene)
const bakedMesh = gltf.scene.children.find((c) => c.name === 'baked')
const portalLightMesh = gltf.scene.children.find((c) => c.name === 'portalLight')
bakedMesh.material = bakedMaterial
portalLightMesh.material = portalLightMaterial // MeshBasicMaterial or ShaderMaterial
})
- Before merging (below), applying one material to everything is done with
gltf.scene.traverse((child) => { child.material = bakedMaterial })
Merging for draw calls
- Dozens of separate meshes means dozens of draw calls. A fully baked static scene needs one mesh: shadows are painted into the texture anyway, so individual objects can never move without looking broken
- In Blender: make the
emissionscollection unselectable, select all baked objects, duplicate withSHIFT + D(never merge the originals, you lose editability), move duplicates to amergedcollection (M), join withCTRL + J(CMD + Jon macOS), rename the resultbaked - Keep the original per-object collections deactivated but present for future edits and re-bakes
- The merged object keeps multiple materials in Blender; irrelevant since materials are not exported
- Export the
bakedmesh plus the emission objects. Result: 4 draw calls for the whole scene (baked, portal, two pole lights). Verify with Spector.js orrenderer.info
Animated details on top of a baked scene
Baked scenes are static; shader-driven details bring them back to life.
Fireflies (custom Points shader)
BufferGeometrywith a randompositionattribute (constrain to the scene bounds, above the floor) and a per-particleaScaleattribute (itemSize 1), rendered asTHREE.Pointswith aShaderMaterial- Vertex shader essentials:
uniform float uPixelRatio;
uniform float uSize;
uniform float uTime;
attribute float aScale;
void main() {
vec4 modelPosition = modelMatrix * vec4(position, 1.0);
modelPosition.y += sin(uTime + modelPosition.x * 100.0) * aScale * 0.2;
vec4 viewPosition = viewMatrix * modelPosition;
gl_Position = projectionMatrix * viewPosition;
gl_PointSize = uSize * aScale * uPixelRatio;
gl_PointSize *= (1.0 / - viewPosition.z);
}
- Multiply
gl_PointSizeby auPixelRatiouniform (Math.min(window.devicePixelRatio, 2)) or particles shrink on retina screens; update the uniform in the resize handler gl_PointSize *= (1.0 / - viewPosition.z)is manual size attenuation, shader points do not getsizeAttenuationfor free- Desynchronize the float by offsetting
sin()with the particle's ownxposition instead of adding another attribute; scale amplitude byaScaleso small fireflies drift less - Fragment shader glow, drawn from
gl_PointCoord(a texture is cheaper, a formula is more controllable):
float distanceToCenter = distance(gl_PointCoord, vec2(0.5));
float strength = 0.05 / distanceToCenter - 0.1;
gl_FragColor = vec4(1.0, 1.0, 1.0, strength);
- The
- 0.1pushes the falloff below zero before the square's edge; without it the formula never reaches 0 and the quad edges show - Material flags:
transparent: true(alpha does nothing without it),blending: THREE.AdditiveBlendingfor the light-like glow,depthWrite: falseto stop particles clipping each other - Drive
uTimefromclock.getElapsedTime()in the tick
Portal surface (Perlin shader)
- The portal disc gets its own
ShaderMaterial. Its UVs must fill the whole 0-1 square: in Blender select the disc's faces andU > Unwrapon its own (rotation does not matter for a circular pattern), re-export - Verify UVs by outputting
gl_FragColor = vec4(vUv, 1.0, 1.0); no gradient means broken unwrap - Pattern recipe, using Stefan Gustavson's classic 3D Perlin
cnoise(3D so time animates the third axis):
vec2 displacedUv = vUv + cnoise(vec3(vUv * 5.0, uTime * 0.1));
float strength = cnoise(vec3(displacedUv * 5.0, uTime * 0.2));
float outerGlow = distance(vUv, vec2(0.5)) * 5.0 - 1.4;
strength += outerGlow;
strength += step(- 0.2, strength) * 0.8;
strength = clamp(strength, 0.0, 1.0);
vec3 color = mix(uColorStart, uColorEnd, strength);
gl_FragColor = vec4(color, 1.0);
- Displacing the UV with one noise before sampling a second gives the swirling look; different small time multipliers per noise keep it organic and slow
- The outer glow (scaled, offset distance-to-center) forces the rim to full strength so the disc meets the bricks in solid color
- Adding a dimmed
step()on top of the smooth noise mixes sharp shapes into the smoothness instead of replacing it clamp(strength, 0.0, 1.0)beforemix()is required: Perlin output exceeds the 0-1 range, and unclamped values extrapolate colors (deep blue to orange turns yellow at the rim)- Expose
uColorStart/uColorEnd(THREE.Color) and the renderer clear color as lil-gui tweaks;setClearColormust be re-called inonChange, changing the stored string does nothing
Common mistakes
- Baking with linked duplicates still linked, so both copies unwrap to identical UVs and bake on top of each other
- Skipping
CTRL + A > Apply Scale, so auto unwrap sizes islands from the un-scaled geometry - Removing hidden faces after unwrapping, invalidating the layout
- Not marking seams before unwrapping boxes, producing distorted spaghetti islands
- Zero unwrap margin, so bake bleed overlaps neighboring islands
- Flipped normals: black baked islands in the texture, invisible faces in Three.js
- Baking with Eevee selected (no Bake panel) or with
Clear Imagechecked, wiping previous per-object bakes - Forgetting to make the
Image Texturenode active in a material, baking into the wrong image - Leaving
Denoiseon during the bake instead of denoising in the compositor - Exporting the raw bake without the compositor pass, shipping burned-out colors and noise (the view transform and denoise are lost in baking)
- Forgetting to save the baked image file, the
.blendsave does not include it - Exporting materials/normals with a baked scene, or forgetting
Apply Modifiersso subdivided objects export as their base cubes - Leaving the imported glTF on
MeshStandardMaterialwith no lights and reporting a black scene as a loader bug - Forgetting
bakedTexture.flipY = false(mirrored texture) orSRGBColorSpace(washed out colors) - Merging the original objects instead of duplicates, losing the editable source scene
- Shipping dozens of draw calls for a static scene instead of one merged
bakedmesh - Points shader without the pixel-ratio multiplier, so particles look tiny on retina displays
- Glow shader without
transparent: true, or withdepthWriteleft on so particles clip each other - Not clamping Perlin strength before
mix(), extrapolating rim colors