with one click
tinyworld-render-performance
// Use when changing Tiny World Builder renderer setup, shadows, smoke, voxel clouds, ghost board render cost, frame loop, or GPU performance.
// Use when changing Tiny World Builder renderer setup, shadows, smoke, voxel clouds, ghost board render cost, frame loop, or GPU performance.
Use when changing Tiny World Builder selection placement, freehand drawing, asset clipboard, cut/copy/paste/duplicate, saved templates, or Stamps panel navigation.
Use when changing Tiny World Builder API, webhook, SSE, MCP, plugin, or automation examples.
Use when changing the home island layout, edge dressing, undersides, draped banners (autoincentive sponsor flag), plane/crop-duster flight paths, banner streamers, or which side of the island is "front".
Use when changing ghost boards, multiplayer preview boards, panning, ghost visibility, jigsaw reveal, or any visibility behavior around the active Tiny World board.
Use when adding or changing persisted user state — settings defaults, audio, camera/orbit, panel positions, feature flags, and the in-app "Save Defaults" pipeline that snapshots localStorage into tinyworld-defaults.json. Also covers the inline-script regex gotcha that has burned us twice.
Use when changing Tiny World Builder Settings modal tabs, panels, controls, rendering/world/material/crowd/AI settings, or settings accessibility.
| name | tinyworld-render-performance |
| description | Use when changing Tiny World Builder renderer setup, shadows, smoke, voxel clouds, ghost board render cost, frame loop, or GPU performance. |
Keep the renderer single-pass and predictable.
Current renderer contract:
renderer.render(scene, camera) straight to the canvas. The only sanctioned post-process is the optional pixelation pass (low-res render target + depth/normal-edge fullscreen quad) gated behind the Pixel size / Pixel depth edge / Pixel normal edge render settings. When renderPixelSize <= 1 or XR is presenting, that pass MUST bypass and fall back to direct rendering — do not introduce other always-on post passes (EffectComposer, screen shaders, additional render targets) without explicit approval.devicePixelRatio.antialias: true; the old smoothing/post pass has been removed.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.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.Pixel normal edge should allocate/render the normal target.ShaderMaterial already injects encoding helpers, so include/apply encodings_fragment at the final gl_FragColor step but do not duplicate encodings_pars_fragment.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.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.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.InstancedMesh buckets where possible, and noShadow on decorative extras;
do not scatter dozens of shadow-casting meshes per tile.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.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.optimizeVoxelObjectGroup(...) after underside/engine/edge dressing is
authored so repeated clamps, boxes, and small slabs batch before LOD copies
multiply their draw cost.mergeStaticBaseMeshesByMaterial(...)
for the fixed shell/underside/greeble meshes. Keep animated plumes, propellers,
weather, waterfalls, and selectable editable-island engines out of that merge.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.WATERFALL_FROTH_SPEED
conservative (currently 0.30) so the white puff layer reads as moving foam,
not flashing particles.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.userData.noShadow = true and keep castReceive() respecting that flag so they block sky/background misses without adding shadow cost or flattening voxel tops.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. Terrain tile roots set keepFadeAtOpaque so they remain on the transparent/depthWrite-off fade material even at 100%; snapping terrain back to the base opaque material exposes diagonal face artifacts that are absent at 99% opacity. 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.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.animatedCellObjects tracks swaying
trees/tufts/crops and smokeHouseObjects tracks chimney sources. Do not
return to scanning every cellMeshes entry each frame for these effects.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.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.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.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.requestAnimationFrame batches.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.setCell(). Do not restore per-island GRID² grass seeding; 50 islands must stay mostly proxies/sparse cells until edited.?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.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.'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.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.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=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.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. When the camera moves below an island, terrain tile roots must stay visible so the side walls remain behind underside greebles; fade only top-side object/extras with renderCullOpacity, and mask that transition with a short event-driven 2D under-occlusion-cloud-wipe sweep. The cloud wipe must not stay as a persistent full-screen fog layer while the camera rests in the transition band. The stats overlay culled row is the quick sanity check that draw/tris totals are responding to what is actually visible.localStorage under tinyworld:render:*.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.GRID. Do not subtract half a tile from this radius, or the board edge starts fading inside the requested size.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.cameraMode: "soft" import/save data to perspective.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.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. 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.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.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.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.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.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.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.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.alphaTest; cloud shadow breakup belongs on each puff's customDepthMaterial so lowering the shadow slider never hides the clouds themselves.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.castShadow = false so they leave the shadow-map pass entirely. Alpha-testing every cloud out in the depth material still costs draw calls.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.UNDER_ISLAND_EFFECT_RENDER_ORDER) so foreground grass, cliffs, fences, and buildings visually occlude them instead of sorting behind the puffs.tinyworld:render:planesEnabled
switch and default off during the current performance pass. When off, do not
load the GLB/textures, tick propellers, update plane banners, or leave crop
dust particles alive. When on, planes remain ambient year-round: only
crop-dusting passes are summer/crop-gated; non-summer or no-crop states
should fall back to banner flyovers rather than hiding the plane system.renderScene(), active ghost boards must be dynamically frustum-culled using the camera view frustum. Apply a safety padding (e.g., GRID * TILE * 0.5) to the bounding boxes to prevent mountain shadow pop-out.frustumCulled = true on the instanced meshes of cheap ghost terrain. Update their geometry bounding boxes and spheres in updateGhostRenderBubble() to match the active preload area so they are culled as a single unit when the camera is panned away.rockGeo, pineGeo, etc.) must have pre-calculated local bounding boxes spanning the chunk size so Three.js can correctly transform their bounds and frustum-cull instanced meshes.Validation:
http://localhost:3000/tiny-world-builder.renderer.getPixelRatio() is at or below the cap.postTarget / postMaterial / postProcessingEnabled references in tiny-world-builder.html.