# Tinyworld Render Performance

> Use when changing Tiny World Builder renderer setup, shadows, smoke, voxel clouds, ghost board render cost, frame loop, or GPU performance.

- Skill: `jasonkneen/tinyworld-render-performance` (Agent Skill)
- Install (CLI): `npx skillmds@latest add jasonkneen/tinyworld-render-performance`
- Raw SKILL.md: https://api.skillmd.com/api/skills/jasonkneen/tinyworld-render-performance/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: jasonkneen (https://skillmd.com/u/jasonkneen)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/jasonkneen/tinyworld-render-performance

---


# Tiny World Render Performance

Keep the renderer single-pass and predictable.

Current renderer contract:

- Default render path is direct except for two explicit render-target systems:
  the optional pixelation pass (low-res render target + depth/normal-edge fullscreen quad)
  and the enhanced-water planar reflection pass. The reflection pass renders the scene once
  from a mirrored camera into `tw-water-planar-reflection`, hides reflective water during
  capture, clips below-water geometry, and is disabled in XR / when enhanced water is off.
  Do not introduce any other always-on post passes (EffectComposer, screen shaders,
  additional render targets) without explicit approval.
- Cap DPR; do not return to uncapped `devicePixelRatio`. Dynamic resolution (`renderDynamicResolution`) is an adaptive multiplier below the user's Resolution slider ceiling: it adjusts effective pixel ratio slowly toward Target FPS and never persists its transient scale.
- Warn on software WebGL renderers (SwiftShader/llvmpipe/softpipe/WARP/etc.) with the dismissible hardware-acceleration banner; many FPS complaints come from browser hardware acceleration being disabled.
- Main WebGL context uses `antialias: true`; the old smoothing/post pass has been removed.
- Brightness/saturation/contrast are lightweight CSS filters on the WebGL canvas, not shader uniforms.

GPU caches (introduced for low-end GPU + visible-distance scaling):

- `geomCache` memoizes `roundedSlab` / `roundedBox` ExtrudeGeometries by their numeric args. Geometries are tagged `userData.cached = true` and shared across every mesh that asks for the same shape. Disposal goes through `safeDisposeGeometry(geo)` — never call `geo.dispose()` directly on these. If you add a new geometry helper that's called more than a handful of times, cache it the same way.
- `getOpenBoxGeometry(w, h, d, skipTop, skipBottom, skipPX, skipNX, skipPZ, skipNZ)` returns a cached `BoxGeometry` with selected face groups removed from the index buffer (matIdx 2 = top, matIdx 3 = bottom). Use it for risers, terrain caps, and hidden-face optimizations. Terrain caps should remain clean flat box slabs rather than `roundedSlab` bevels: flat top surfaces plus vertical cap thickness provide depth/substance without the repeated chevron artifacts caused by chamfered per-tile bevels. Grass caps should overhang the dirt body so exposed edges read as green lip over dirt. Open-box geometries are still `userData.cached = true` — never mutate or dispose.
- `vbox()` accepts `skipTop` / `skipBottom` / side-skip options and routes those pieces through cached open-box geometry. Use these flags for buried voxel faces in authored assemblies, especially floating-island underside slabs and inverted roof layers; do not render closed boxes for the inside of the island mass.
- Voxel terrain panels must stay batched inside each tile: bucket panels by cached open-box geometry and material, then emit one `THREE.InstancedMesh` per bucket. Strip panel bottoms, internal side faces, and neighbour-hidden edge sides with `getOpenBoxGeometry`; do not go back to one `THREE.Mesh` per small terrain panel at 8x8/12x12 resolutions.
- Object-free, level-1 grass tiles use the simple flat-grass fast path even when voxel terrain is enabled. A blank island must not spend hundreds of draw calls on per-cell voxel grass panels/details; reserve voxel terrain detail for edited, raised, non-grass, or occupied cells.
- Simple blank-grass terrain is receive-only for shadows. Do not put empty grass slabs into the shadow-map pass; they receive object shadows but should not double the blank-island draw call count.
- Preview/ghost board rendering is forced off by default: Preview distance/window/opacities persist as zero and the Settings controls are removed. Do not let hidden Preview settings create ghost boards or fade traversal on a blank island.
- When voxel terrain is enabled, keep the top surface voxelized but render exposed riser/body sides as solid shader-textured walls. Avoid thousands of side panels: they create cracks/transparent-looking corners and waste draw/instance budget. Keep same-or-higher neighbour side culling intact so shared internal sides are not rendered.
- Mesh Terrain's real-material path may greedily merge flat same-material top
  rectangles, but keep exposed bevel/chamfer edges and sculpted drops visibly
  chunky. Future side-wall greedy strips should merge only contiguous faces with
  identical material, top height, and bottom height.
- Pixelation shader AA should work in pixel mode, but only through edge/depth/normal detection. Do not use a broad fullscreen blur; it smears terrain texture and UI-like decals. Shader AA must not force the normal prepass by itself; only `Pixel normal edge` should allocate/render the normal target.
- Pixel post shaders are variant-built: the depth sampler uniform exists only
  when depth edge sampling is enabled, and the normal sampler uniform exists
  only when normal edge sampling is enabled. Depth edge sampling uses the
  separate RGBA-packed depth target; do not add unused sampler uniforms to the
  fullscreen material because WebGL warns when no target is bound.
- Pixel post shaders must preserve the renderer output color space. In Three r185 `ShaderMaterial` injects color-space helpers, so include/apply `colorspace_fragment` at the final `gl_FragColor` step; do not use the old `encodings_fragment`/`encodings_pars_fragment` chunks.
- Backdrop/game-screen vignette should remain a cheap CSS overlay variable, not another WebGL post pass. Keep it separate from scene brightness/lighting so it can frame the background without retuning materials.
- Sky/background colour controls are direct scene/CSS settings, not post passes: `Sky blue depth` darkens the shader sphere and CSS backdrop, `Sky blue saturation` pushes the same blue hue harder, and `Undercloud width` rebuilds the small under-island cloud ring. `Cloud height` also controls undercloud depth below the island, slightly farther than the upper cloud distance, so one height adjustment moves both cloud layers. Keep the undercloud layer as a handful of instanced cloud-puff groups attached below the floating island; do not make a full volumetric cloud field or reuse the full multi-mesh shadow-casting sky cloud factory there.
- Night star backgrounds use the single `star-vault-equirect-sphere` mesh and
  a procedural star-only canvas texture, blended only at dusk/night through
  `renderStarVaultStrength`. Keep it as a non-depth-writing sky sphere, not an
  EffectComposer/background pass. Do not load painted sky/land/cloud bitmaps
  into this sphere: equirect wrapping makes horizon imagery smear under/around
  the island. Keep the shader horizon mask so stars fade out below the island.
- Floating-island underside depth should use a small number of cached voxel/box slabs and cached utility cylinders attached to `homeBorderGroup`, not per-cell underside geometry. Treat underside/edge/rocket/utility dressing as decorative scenery: set `castShadow = false` and `receiveShadow = false` after building it so hundreds of tiny underside meshes do not enter the shadow-map pass.
- Floating-island shell sides must stay visible from underside/grazing angles, including secondary editable islands. Keep island underside materials double-sided, keep proxy side shells on double-sided cached material clones, and keep the persistent recessed side-backing ring behind edge greebles on home and duplicate island bases. Let the scene-level island culling hide/show the shell group; do not rely on per-mesh frustum culling for the shell slabs because it can clip side/back faces while the island itself is visible.
- Animated waterfall froth/chunks should stay batched as `THREE.InstancedMesh`
  pools per exposed water edge, not one mesh per puff/drop. Keep the instance
  matrix update inside `updateWaterfallEffects()` and mark the pools
  non-shadowing.
- Terrain/path storytelling details should be batched or rare. Wheel ruts,
  surface flecks, pavers, pebbles, grass-edge roots, and small path signs/crates
  belong in the voxel terrain surface-detail pass with cached geometries,
  `InstancedMesh` buckets where possible, and `noShadow` on decorative extras;
  do not scatter dozens of shadow-casting meshes per tile.
- Waterfall curtain/blade variety should come from the shared shader sheets
  (`getWaterfallCurtainMaterial()` / `getWaterfallSurfaceMaterial()`), so each
  exposed water edge uses one vertical sheet plus one surface-flow sheet instead
  of many blade/tail meshes. If a future pass merges whole rows of waterfalls,
  keep the same shader-sheet contract and aggregate spans above the per-cell
  tile factories.
- Voxel object factories that emit repeated `vbox()` pieces should finish with
  `optimizeVoxelObjectGroup(...)` so matching geometry/material pieces become
  local `THREE.InstancedMesh` buckets. Keep the legacy factory code intact; the
  optimization is a render-layer replacement for repeated live meshes, not a
  removal of the authored object.
- `customParts` sphere/ellipsoid primitives use the shared
  `getCustomPartEllipsoidGeometry(...)` cache and per-mesh scale. Do not create a
  fresh `SphereGeometry` for every generated rounded part; repeated domes,
  balloons, canopies, and tanks should share cached VBOs and be skipped by
  `safeDisposeGeometry`.
- `disposeGroup(group)` disposes geometries by default, but materials remain
  shared unless a mesh explicitly opts in with `userData.ownMaterial`,
  `userData.ownedMaterial`, or `userData.disposeMaterial`. Use those flags only
  for true per-instance material/texture ownership (scratch FX clones, imported
  unique assets, temporary previews). Never set them on shared `M.*`, cached
  fade materials, or cached shader/material singletons.
- Async texture/resource callbacks must repaint through `renderSceneIfReady()`,
  not direct `renderScene()` calls. These callbacks can fire while classic
  scripts are still loading; `bootApp()` flips `setRenderSceneReady(true)` only
  after scene setup is complete and before the animation loop starts.
- Home and duplicate floating-island bases count as voxel object assemblies too:
  run `optimizeVoxelObjectGroup(...)` after underside/engine/edge dressing is
  authored so repeated clamps, boxes, and small slabs batch before LOD copies
  multiply their draw cost.
- After batching a floating-island base, run `mergeStaticBaseMeshesByMaterial(...)`
  for the fixed shell/underside/greeble meshes. Keep animated plumes, propellers,
  weather, waterfalls, and selectable editable-island engines out of that merge.
- Distant mini-world dressing is background scenery: merge it by material after
  build and keep it non-shadowing/non-pickable. It must not add hundreds of
  shadow-casting meshes to a blank board.
- Contiguous same-style voxel fence rows should render as span meshes through
  `findFenceRenderSpan()` / `makeVoxelFenceSpan()` while keeping per-cell
  `world` intent and tile picking unchanged. Refresh the full connected fence
  component after edits so old anchors/non-anchors do not linger.
- Fence edge posts belong on the true tile edge/corner coordinates (`±0.50`),
  not inset inside the cell. This lets perpendicular sides and neighboring
  cells visually share one corner post while preserving the same adjacency
  intent.
- Waterfall froth/foam should drift slowly. Keep `WATERFALL_FROTH_SPEED`
  conservative (currently `0.30`) so the white puff layer reads as moving foam,
  not flashing particles.
- Enhanced-water planar reflection should capture only when enhanced water is
  on, visible reflective water exists, and the viewer is not in a synchronous
  load. The mirror plane comes from the nearest rendered/known water tile to the
  camera target; `Y=0` is only a last-resort fallback when no water plane can be
  found.
- The object/stamp voxel bevel is a persisted render setting (`tinyworld:render:voxelBevel`) applied inside `vbox()` through cached centered voxel box geometry. It is intentionally fine-grained (0.001 steps) so tiny voxels can keep only a slight softened edge. Keep it subtle and global; do not hand-bevel individual stamps unless they need a genuinely different silhouette.
- Terrain tile geometry must stay inside the logical cell footprint. Do not use
  x/z overhangs on tile tops, risers, voxel top panels, riser panels, or cheap
  ghost terrain; every terrain tile should share the same `TILE` bounds in x/z.
  If cracks appear, solve them with material/shader treatment or camera/post
  tuning, not by overlapping neighbouring terrain vertices or faces.
- Terrain leak blockers belong under the dirt/riser body as bottom caps, not beneath the visible top surface. Mark those caps `userData.noShadow = true` and keep `castReceive()` respecting that flag so they block sky/background misses without adding shadow cost or flattening voxel tops.
- Terrain/island side and riser meshes must not receive projected shadows. Tag side-only risers/kerbs/rims with `userData.noReceiveShadow = true` and keep `groundReceiveOnly()` respecting it; otherwise sun shadows from top content draw large diagonal bands on vertical side materials that appear to rotate while orbiting the camera.
- Far zoom/VOD also disables terrain shadow receivers via `updateTerrainShadowReceiversForCamera()` so cap/top meshes that necessarily combine top + vertical side faces cannot show distant diagonal shadow bands. Preserve the near/far cutoff as a zoom-aware terrain-only shadow LOD; object shadows can read close-up, but distant terrain sides should stay clean.
- `fadeMatCache` shares fade materials in `FADE_BUCKETS = 16` opacity buckets keyed by (base material UUID, grayscale flag, bucket, keepFadeAtOpaque). `prepareFadeable` and `applyElementOpacity` look up via `pickFadeMaterial(baseMat, grayscale, displayOpacity, keepFadeAtOpaque)` instead of cloning per mesh. Home/active terrain must switch back to opaque depth-writing base materials at 100% opacity so island side shells cannot sort through the top at grazing angles; reserve `keepFadeAtOpaque` for preview/ghost terrain that still relies on fade blending. Cached materials are tagged `userData.cachedFade = true` and must never be mutated or disposed — they're shared by every mesh in their bucket. If you need a per-instance opacity (e.g. squash anim), clone the material yourself and tag it so it gets disposed individually.
- Ghost boards are built incrementally via `pendingGhostBoards` queue, drained inside `animate()` by `processGhostBoardQueue(budgetMs)` with a small per-frame budget. `ensureGhostBoardsAroundTarget` only enqueues — it must never build synchronously, or load/reset/visible-distance changes hitch the main thread.
- Per-frame object work is set-based: `animatedCellObjects` tracks swaying
  trees/tufts/crops and `smokeHouseObjects` tracks chimney sources. Do not
  return to scanning every `cellMeshes` entry each frame for these effects.
- Pixel-drag panning must not call `ensureGhostBoardsAroundTarget()` directly
  on every pointer event. Route panning through `maybeEnsureGhostBoardsAroundTarget()`
  so preview-board enqueue/fade work only runs after a meaningful target move
  or a board-coordinate change; settings/reset/import paths may still force an
  immediate ensure when they deliberately change preview state.
- Ghost detail reevaluation is dormant by default because Preview boards are
  currently full-fidelity only. Keep `ghostDetailReevaluationActive` false unless
  a non-full-detail board exists; otherwise the animation loop should not scan
  every ghost board several times per second just to confirm `'full' === 'full'`.
- `applyElementOpacity()` caches the last display opacity applied to each root.
  Preserve that no-op guard so repeated fade/bubble updates do not traverse an
  unchanged tile/object subtree or redo fade-material bucket checks.
- Per-object appearance texture overrides are relative multipliers on top of
  the base material's `userData.worldTextureScale`, not absolute world scales.
  Keep `customTextureMaterial()` multiplying by the base material scale so roof
  shingles/slate and brick courses do not balloon when selected through the
  inspector/appearance path.
- `applyWorldUVs()` patches Lambert materials through `onBeforeCompile`, so it
  must also assign a matching `customProgramCacheKey()` (scale + seam/edge
  options). Any clone/fade/shaded/shell material that copies a world-UV
  `onBeforeCompile` hook must copy `customProgramCacheKey` too; otherwise Three
  Three may reuse a stock/local-UV program and side textures appear to rotate
  when the camera orbits.
- Generated/imported world application supports sliced progressive rendering. In sliced mode, `applyState(..., { sliced: true })` sorts terrain and object/detail passes by distance from `opts.renderOrigin` or the current camera `target`, so visible/nearby cells appear before farther cells. Preserve that distance-ranked ordering when changing generation rendering. Demo/stress routes may pass `skipGhostBoards: true` to keep a large home board from also preloading preview boards; in that mode `applyState` should zero the in-memory preview distance, sync the ghost budget, and clear ghost boards without persisting render settings.
- Generated random islands request a one-shot terrain bake through
  `window.__tinyworldRequestTerrainBake()` after the sliced apply pass finishes.
  This keeps merged terrain a render optimization: picking resolves baked cells
  from hit coordinates, and terrain edits unbake back to live per-cell tiles.
- Stamps panel card thumbnails share the toolbar thumbnail renderer/cache but should not synchronously build every 3D thumbnail during open/search/category renders. Keep fallback thumbs immediate, cancel stale card-thumb queues on panel re-render, and drain expensive card thumbs in small `requestAnimationFrame` batches.
- Home grids above the windowing threshold are **intent-full / render-windowed**: `world[][]` may hold the full 512×512 board, but `cellMeshes` must only hold the camera-centred home render window. Keep large-grid bulk load/clear paths on intent writes plus `requestHomeRenderWindowSync()`, not `GRID²` mesh rebuilds. Keep `world[][]` sparse: virtual default grass comes from `getWorldCell()`/`ensureWorldCell()`, not from preallocating `HOME_GRID_MAX²` cells. Any direct `world[x][z]` read on an editing/API path must either guard the row or use `getWorldCell()` so untouched large-grid rows still behave as default terrain.
- Duplicate editable islands follow the same sparse-intent rule: a new island gets one pickable default grass surface and only edited cells get materialized through `setCell()`. Do not restore per-island `GRID²` grass seeding; 50 islands must stay mostly proxies/sparse cells until edited.
- Use `?demo=island-stress&islands=50&stats=1` or `window.__runIslandStressDemo(50)` to measure duplicate-island scaling. The stats overlay reports island LOD counts (`full/proxy/hidden`) plus the active full-detail budget and live `cellMeshes`. Keep duplicate editable islands under a capped full-detail LOD budget; the selected island remains full and only the nearest in-budget islands keep full bases/engines/content, while the rest use proxies.
- Preview/ghost boards are full `GRID²` boards today. Until they are chunked/windowed too, clamp 96+ grids to `ghostRadius = 0` / preview distance 0, and keep 128+ boards preview-disabled. Otherwise a single neighbour at 128+ explodes into tens of thousands of meshes/instances per board. Do not degrade visible Preview objects into cheap proxy boxes/cones/pyramids; if full-fidelity preview is too expensive, reduce or disable preview rings instead. If the cheap ghost terrain instancing path is used, clear its global buckets when ghost boards are cleared/disabled/resized so stale instanced terrain cannot remain in the scene.
- To keep draw calls low on full-detail ghost boards, all terrain tiles and objects are merged by material and fade role (`'tile'` / `'object'`) into single meshes using `mergeGhostTerrainByMaterial(board)`. The merged meshes are centered at the board center to preserve distance-based fading via `opacityAtWorldPosition`. Raycasting resolves click/hover cell coordinates `(gx, gz)` using `resolveRaycastCell(h)` by mapping hit coordinates relative to the board bounds. When a ghost cell is materialized (clicked or edited), `removeGhostCellMesh` triggers `rebuildGhostBoard` to regenerate the merged meshes without the edited cell.
- Full-quality ghost tiles are `THREE.Group` roots with many static leaf meshes. `mergeGhostTerrainByMaterial(board)` must traverse those leaf meshes, merge by each leaf's base material, and skip special animated/effect meshes such as waterfall/weather children. Do not regress to only checking direct board children; that leaves the merge path effectively disabled.
- The final generated/imported settle pass should only rebuild
  adjacency-sensitive terrain (paths, water/shore neighbours, bridges), not the
  entire board.
- Initial/full-scene render paths should render board tiles immediately and
  animate only props/buildings/extras. Do not reintroduce tile drop-in for the
  starter board, saved-state restore, import, or generated-world base pass; the
  terrain is the stage, not part of the entrance animation.
- If grass/water/path show tiny specks, pavers, ripples, or foam before the
  rest of a tile visually settles, inspect `makeTile()` decals and the reveal
  pipeline first: grass flecks, water insets/ripples/foam, and path
  pavers/scuffs are real geometry just above the tile top. During opacity
  reveal they can read as transient artifacts because faded materials use
  transparent/depthWrite-off buckets, and sliced builds may briefly render
  adjacency-sensitive path/water/shore details before the final settle pass.
- Stats overlay (`?stats=1` or backtick key) reads `renderer.info` and reports FPS, draws, tris, geoms, mats, programs, textures, ghost-board count + queue depth. It also shows the repaint profiler when visible: render submit buckets, frame tick buckets, `setCell` refresh/plan/save time, tile/object/extras rebuild time, queue drains, and dispose traversal. Use `?repaint=1` or `window.__tinyworldRepaintProfile.setEnabled(true)` for a focused repaint breakdown, and `window.__tinyworldRepaintProfile.snapshot()` to inspect the current top buckets.
- Camera culling runs immediately before `renderer.render()` in `updateSceneVisibilityForCamera()`. Keep this scene-level pass in addition to mesh `frustumCulled`: it hides off-frustum home/ghost/editable-island roots before the camera and shadow passes. For now, under-island top-content hiding is a hard cutoff: top-side objects/extras stay fully opaque until the camera is far enough below the island that the top side should not render at all. Do not reintroduce partial transparent fade/wipe without visual QA for grazing-angle transparency and side-shell sorting. The stats overlay `culled` row is the quick sanity check that draw/tris totals are responding to what is actually visible.
- Current default color grade is intentionally stylized: resolution 75%,
  brightness 108%, saturation 109%, contrast 116%, lighting 62%, ambient fill
  100%, front fill 34%, side fill 26%, back fill 22%, tilt blur 2.1px, and
  tilt focus 65%.
- Render settings are user-adjustable and persisted in `localStorage` under `tinyworld:render:*`.
- Tilt-shift blur stays active while the camera is moving, panning, zooming,
  home-tweening, or first-person walking/look-moving. Keep `markCameraMoving()`
  as a stable no-op hook for those movement paths, but do not hide or pause the
  tilt-shift pseudo-element during interaction unless the user explicitly asks.
- Scene/screen controls must keep working in the direct-render path: resolution, shadow quality, lighting, visible distance, visible size, backdrop glow, clouds, tilt-shift blur/focus, and ghost opacity.
- Preview window is the reveal square around the camera target in tile-width units. It auto-scales by board size and can be user-adjusted, but it must never be smaller than `GRID`. Do not subtract half a tile from this radius, or the board edge starts fading inside the requested size.
- Preview opacity / floors / objects are user-adjustable display multipliers for surrounding preview boards. The home board stays fully opaque regardless of those controls.
- Do not add new post-only shader controls beyond the existing pixelation/antialias controls unless the user explicitly asks for them. Pixelation/AA post-process targets/materials are constructed lazily inside `ensurePixelResources` and resize through `setSize`; do not pre-allocate them at startup, and do not leak the normal-target/override material when `renderPixelNormalEdge` is 0. Depth/normal edge strengths should default to 0: they outline real tile bevels, risers, overhangs, shore/path/water decals, and shadowed side geometry, which can read as terrain artifacts under pixelation. Shader antialias is a colour-only edge-aware smoothing pass; keep it separate from depth/normal outlines.
- Soft perspective has been removed from the app. Keep camera mode choices to top-down, isometric/orthographic, perspective, and first-person; normalize any legacy `cameraMode: "soft"` import/save data to `perspective`.
- Atmosphere fade should use native `scene.fog` (`THREE.Fog`) so distant scenery colour-fades inside the direct renderer path. Keep fog near/far recomputed from camera distance + visible span after camera updates, expose it as a persisted render setting, and disable it when `scene.background === null` for AR passthrough. The fog colour should be derived from the live sky/background but blended heavily toward a warm neutral haze, not the raw saturated sky blue, so distant islands do not wash out cyan.
- The Generate modal's landscape **"Realistic" option no longer uses the LandscapeEngine continuous mesh** — it now builds flat-top voxel blocks through the mesh-terrain system (`applyRealisticVoxelLandscape()` in `27-landscape-engine.js` → `window.__tinyworldMeshTerrain.generate(...)`, sampling the same `sampleLandscapeCell()` height/biome). Realistic keeps `landscapeMeshMode = false` (normal tiles + voxel overlay). **Low-poly still uses the LandscapeEngine** continuous mesh (`landscapeMeshMode = true` via `initLandscapeMesh()`), and planet-underlay still uses its realistic Lambert path. See `.codex/skills/tinyworld-mesh-terrain`.
- LandscapeEngine realistic terrain in TinyWorld may use built-in vertex-colour Lambert materials so native shadow maps and `scene.fog` work, but **do not replace low-poly LandscapeEngine terrain with Lambert**. Low-poly landscape must keep its custom cel `sandMatLowPoly` shader; otherwise the low-poly render option visually regresses into realistic terrain. Keep near realistic terrain receiving shadows and near rocks/flora casting shadows; far LOD terrain should stay non-shadow-receiving/casting to avoid wasting GPU. Low-poly LandscapeEngine clip bounds need opaque `landscapeCutCaps` retaining walls at the cutaway edges; don't disable those helper caps unless another solid side-fill replaces them. `LandscapeEngine.update()` should cache its near/far chunk signature and skip wanted-set rebuilds while the camera remains in the same streamed chunk; do not reintroduce per-frame Set/string churn in the steady state. Keep those caps shallow enough to stop around the island underside (`-DIRT_H`), colour them as dark cliff/soil (grassland caps must not use bright top-grass green), and do not fade clipped terrain/water edges to blue fog/glow — the opaque cap is the boundary treatment. For planet-underlay realistic terrain, keep the Lambert path but inject only the small `setPlanetFog()`/`terrainMat.onBeforeCompile` underlay haze uniforms; do not switch the low-poly planet to Lambert or add a global post blur. `setPlanetFog()` must defensively ensure those uniform holders exist before writing `.value`, because restore/query boot can call it before a compiled material has all underlay fields populated.
- When testing a planet/ground surface below floating islands, keep it as a separate lowered LandscapeEngine instance (`planetLandscapeEngine`) instead of flipping `useLandscapeEngine`/`landscapeMeshMode`. That preserves the editable floating board and ghost-board behavior while the underlay streams independently. Treat that underlay as backdrop, not an active play surface: keep enough terrain mesh fidelity and extent for the planet to read as a broad detailed surface (near radius 0, far radius about 2 with larger far chunks, far chunk size around 2600, far res around 24), but remove the expensive active-surface costs: no rock/flora scatter, no water plane by default, no shadow participation, one near/far chunk build per throttled stream tick, and only a couple of cheap transparent atmosphere sheets. If a proof route shows a clipped/partial horizon because the normal animation loop is paused or throttled, use a tiny setTimeout warmup drain that builds one pending chunk at a time and re-renders, rather than priming dozens of chunks synchronously. Patch both `LandscapeEngine.js` and the active `engine/landscape/chunks.js` mixin when changing chunk builders; the mixin overrides `_makeChunk()` at runtime. Planet distance is user-adjustable through the Generate modal / query `planetDrop`; changing it should move the lowered LandscapeEngine group and rescale the between-layer atmosphere sheets, not alter the floating board height. Add the island-to-planet atmosphere as cheap transparent world-space haze sheets between the board and underlay (`planetAtmosphereGroup`), not as a global post blur; depth testing keeps the editable island crisp while softening only the lower landscape behind it. The underlay should read as far below, not as wallpaper behind the island: use the planet distance uniforms (`planetDistanceEffect`, tint colour, desaturation, dimming) on low-poly/realistic/water shaders and tint/fade built-in rock/flora materials instead of adding a full-screen blur or global post pass. Mark non-editable underlay roots with `userData.noPointerPick` and exclude them from `pickTile()` raycast roots; otherwise every mouse move raycasts through all lowered terrain chunks/flora and causes visible app stalls. Do not set `material.needsUpdate = true` on recurring haze colour syncs unless transparency/depth-write mode actually changed.
- Shadow maps should stay modest unless a visual defect proves otherwise.
- Keep shadow bias/normalBias/radius tight for voxel-scale geometry. Large `normalBias` or soft radius values make thin roofs, columns, crop stems, fences, and trim detach from their shadows, which reads as light leaking through the model in pixel mode. Do not force `material.shadowSide = THREE.FrontSide` globally: closed box roofs and voxel panels will self-shadow and show diagonal shadow-acne hatching that looks like an unwanted texture.
- Rain/snow should use in-world instanced box particles. Rain impacts use transient instanced ring-ripple splash pools plus heavy-rain/storm circular puddle buildup; snow impacts add persistent low-opacity square surface patches that visually build up. Snow is winter-only: selecting snow switches to winter, and changing to any non-winter season clears snow weather. Keep impact decals lifted above beveled tile tops (`WEATHER_SURFACE_PAD` + decal/ripple lift), but leave depth testing enabled so they cannot render through terrain sides, objects, or underside geometry. Do not reintroduce CSS/screen-space rain/snow overlays or always-on per-tile weather panels. Impacts should only appear on rendered tile surfaces. Weather state should affect every visible element through shared material tinting, including preview boards. Weather intensity is severity: low = light rain/flurries, high = storms/snowstorms with stronger slant, darker ambience, more active instances, global material tint strength, and water/snow buildup. Intensity and splash/buildup controls intentionally overdrive up to 300%; keep emission/opacity visibly obvious at max. Storm is an explicit rain mode that forces storm-strength rain visuals while preserving the same splash/buildup controls. Seed surface marks when weather or splash/intensity changes so puddles/snow are visible immediately, not only after waiting for random impacts. Clamp impact decals inside their tile footprint so rings/puddles/snow patches never overhang visible board edges.
- The sun is the only shadow caster. Its angle is fixed in world space
  (`SUN_OFFSET = (7, 12, 5)`) but its position and `sun.target` follow
  the camera `target` via `updateSunFollow()` (called from
  `updateCamera()`). The shadow frustum is `±SHADOW_HALF (20)` in light
  space so shadows stay correct wherever the user pans — never anchor
  the sun at the world origin again.
- Lighting stack: `AmbientLight` (flat fill so shadowed sides never go
  black) + `HemisphereLight` (warm sky/ground gradient) + the
  directional sun + non-shadowing front/side/back directional fill lights.
  Keep sun/shadow strength separate from fill controls so dark object faces
  can be lifted without increasing cast-shadow cost. Keep neutral/default
  lighting conservative now that there is no post pass; time-of-day
  hemisphere scaling should normalize against the day anchor (`0.90`), not
  the raw constructor value, or midday blows out.
- The non-shadowing environment/fill stack is currently lifted by
  `ENVIRONMENT_LIGHT_MULTIPLIER = 2.0` in `02-cameras-lighting.js`; this
  brightens ambient/hemi/fill/model-stamp safety lights without increasing the
  shadow-casting sun.
- Imported GLB/model stamps also have named non-shadowing safety lights:
  `model-stamp-import-ambient-fill` and
  `model-stamp-import-directional-fill`. They are adapted from the
  user-supplied Mugen87 StackOverflow ambient/directional baseline, scaled by
  the render lighting/ambient sliders and time-of-day/weather code, and must
  follow the camera target without casting shadows.
- Accent lighting for the starlit look is limited to non-shadowing local
  `SpotLight`/`PointLight` instances that follow the camera target and fade in
  at dusk/night. Do not enable shadow maps on these lights; the directional sun
  remains the only shadow caster.
- Placeable lamp/spotlight stamps may include capped non-shadowing
  `PointLight`/`SpotLight` sources, but the visible falloff should be cheap:
  shared additive ground decals, wall/halo sprites, and haze meshes tagged
  `userData.lightVisual` so `prepareFadeable()` does not replace their shader
  materials. Keep the spotlight cone/decal source at the fixture and widen/fade
  it forward onto the ground, not back toward the object.
- Building windows can switch to `M.windowLit` at dusk/night via per-window deterministic seeds. Keep this set-based (`buildingWindowObjects`) and update on time-of-day changes, not by scanning every cell each frame. Window spill/wall glow/halo helpers should be tagged `userData.windowLightEffect` and skipped by fade-material traversal so their additive shader materials survive ghost/reveal opacity setup.
- Building light readability depends on the exterior halo and ground spill. Keep
  the window halo bloom broad enough to read from normal editor distance
  (`windowW * 2.2`, `windowH * 2.0` in `createWindowLightEffects()`), and do
  not reintroduce an unmasked wall-surface glow plane that paints light through
  surrounding brickwork.
- The blast shield is the supplied `VoxelShield` class/API port in
  `engine/world/40-shield-system.js`, not a replacement design. Preserve
  `VoxelKit`, `BlastPanel`, `CornerKeystone`, `ShieldRing`, `ShieldDemo`, and
  `window.VoxelShield`. Keep panel/keystone voxel pieces optimized with
  `optimizeVoxelObjectGroup(...)`, and cap actual `PointLight`s while retaining
  emissive rune/glow cubes.
- Ghost boards should participate in the shadow pass — same sun, same shadows everywhere. If Preview/ghost shadows disappear, first check that `prepareFadeable` has not forced ghost meshes to `castShadow = false`, and that any merged/batched ghost terrain explicitly preserves `receiveShadow`/`castShadow` after replacing source meshes. The factory-level `castReceive` / `groundReceiveOnly` choices should apply uniformly unless there is a deliberate, visible-quality-approved LOD exception.
- Voxel cloud visual opacity is independent from Cloud shadow. Do not drive visible cloud materials with `alphaTest`; cloud shadow breakup belongs on each puff's `customDepthMaterial` so lowering the shadow slider never hides the clouds themselves.
- Cloud rim lighting is material/shader tint only: voxel cloud material clones
  use a small warm emissive, while soft cloud shader quads use a `rimStrength`
  uniform. Keep rim lighting separate from `renderCloudShadow` so cloud shadow
  cost/visibility rules do not change.
- Sky clouds should stay above and around the active build plane, not directly
  over rooftops/buildings in overhead editor views. Keep the high visual minimum
  and no-fly/perimeter exclusion in `23-particles-clouds.js` and
  `31-cloud-sea.js` aligned with the Cloud height settings/defaults so clouds
  do not read as textures mapped onto buildings.
- The soft cloud sea is a transparent overdraw hotspot. Keep it as one merged
  billboard mesh, cap the puff count near ~1k rather than returning to the old
  1800-quad veil, and discard near-transparent texels in the shader. When
  `renderCloudStyle === 'soft'`, `updateClouds()` should not animate hidden
  voxel/under-island clouds; `tickCloudSea()` owns soft-cloud motion.
- When Cloud shadow is 0, cloud puffs should set `castShadow = false` so they leave the shadow-map pass entirely. Alpha-testing every cloud out in the depth material still costs draw calls.
- Cloud shadow defaults to 0. Visible clouds may remain, but they must not enter
  the shadow pass unless the user explicitly raises Cloud shadow.
- Smoke particles must be capped and must not cast/receive shadows.
- Per-particle opacity should use the shared quantized particle material cache and skip material assignment when the quantized bucket has not changed. Do not clone or assign particle materials every frame for smoke/dust.
- Ambient underside wear/debris should use a separate capped particle pool with
  cached tiny box geometry and the shared quantized particle material cache, so
  slow falling crumbs do not consume chimney/impact smoke capacity. Make them
  visible by tuning cap/rate/size within that pool before adding another effect
  path. Object landing bursts should also spawn through that same pool at the
  object's x/z instead of creating a second underside particle system.
- Floating-island rocket/engine smoke puffs should reuse the existing capped `smokeParticles` pool and cached particle materials. Impact-triggered puffs from heavy drop-ins should be brief, grey/dark-grey, non-shadowing particles rather than a second smoke system.
- Home-island rocket/jet plumes should be a few yaw-facing shader sheets using
  cached plane geometry and shared materials, not dozens of individual animated
  flame cubes per engine. Rotate the sheet only around the local Y axis so it
  reads from the camera without copying the full camera quaternion or moving the
  thrust off the nozzle. The tick path should update one shared time uniform plus
  light sheet scale/position pulses, while intermittent smoke stays on the capped
  particle pool. Keep the old voxel plume object builder available as an
  inactive legacy helper so those objects can be reused later without
  re-authoring them.
- Duplicate-island lift propellers should switch to one cached circle geometry
  plus one shared dark shader material for the high-RPM blur/strobe disc. Keep
  the physical blade groups visible only during startup/slow spin, keep bulky
  outer caps and old hub-block cubes opt-in 

…(truncated)
