From 77bcf02958082f34595b0ea0d933a42c9deaa6af Mon Sep 17 00:00:00 2001 From: Sagyam Thapa Date: Sat, 7 Nov 2026 10:17:01 +0545 Subject: [PATCH 1/2] City: retune daylight for the realistic models The day's fill was a flat white ambient brighter than the sun, tuned for the low-poly kit. It lit every face alike, so the realistic buildings looked flat and their shadows faint, and late afternoon looked overcast. - Most of the day fill moves to the hemisphere light (cool sky above, warm ground bounce below); the ambient keeps its night share (DAY_FILL). - Warmer, stronger sun; orange sun and lilac shade at golden hour, with a little more exposure then. - Nearer haze, so the city stands off from the woods behind it. Same lights, same cost. Mean brightness (bench-app LUMA, city): 13h 107.7 -> 108.1, 17:30 79.6 -> 68.9 (warmer, deliberately), 22h 23.0 -> 22.2. Co-Authored-By: Claude Opus 5.5 --- docs/APP.md | 3 +++ docs/ROADMAP.md | 1 - ui/src/city/scene.ts | 37 ++++++++++++++++++++++++------------- 3 files changed, 27 insertions(+), 14 deletions(-) diff --git a/docs/APP.md b/docs/APP.md index 0ce028c..c2d66b4 100644 --- a/docs/APP.md +++ b/docs/APP.md @@ -164,6 +164,9 @@ Open items are in [ROADMAP.md](ROADMAP.md#rendering). "nothing to be proud of, nothing to lose". Visuals must make loss hurt. - **Realistic over low-poly**; the kit stays only as a fallback. - American and European styles mixed; real-world sizes per model. +- **Daylight from a warm sun and a sky hemisphere**, not a flat ambient: an + ambient lights every face alike and flattened the models (`light()` in + `scene.ts`). Late in the day the sun turns orange and the shade lilac. - Landmarks include civic buildings, squares, parks and monuments on half or whole blocks; the master plan reserves their ground. - 30 fps for an end-game city on a weak iGPU is acceptable. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 600f96c..5a27192 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -63,7 +63,6 @@ with a root shell open in another terminal for the PAM checks. merged `Parts`. - [ ] Shrink the pack further: the AO bakes are ~90 MB of it, one channel stored as BC7; BC4 (RGTC) would halve them. -- [ ] Retune lighting: it is still tuned for the flat kit. ## Packaging diff --git a/ui/src/city/scene.ts b/ui/src/city/scene.ts index 097a8ba..6673027 100644 --- a/ui/src/city/scene.ts +++ b/ui/src/city/scene.ts @@ -49,11 +49,18 @@ const SUN_STEP = 0.0125; /** * ibl: the neutral ambient light that stands in for the environment light * rough materials lost, per unit of environment intensity. Unlike the town's - * faint sky, the room environment is most of the city's fill light (night - * included); 6.5 matched the old frames' mean brightness by day and by night - * (scripts/bench-app.ts with LUMA=1). + * faint sky, the room environment is most of the city's fill light by night; + * 6.5 matched the old frames' mean brightness (scripts/bench-app.ts with + * LUMA=1). By day most of it moves to the hemisphere light (DAY_FILL). */ const FILL = 6.5; +/** + * light: share of FILL kept in full day. A flat ambient lights every face + * alike, so it flattened the realistic models and their shadows; the + * hemisphere light (sky above, warm ground bounce below) gives roofs, sunny + * walls and shade their own values for the same cost. + */ +const DAY_FILL = 0.45; /** clamp: caps brightness before the bloom (see the constructor). */ const CLAMP = { @@ -120,7 +127,7 @@ export class City { private bench: Bench | null = null; private stars: THREE.Points; private sunLight = new THREE.DirectionalLight(0xffffff, 3); - private hemi = new THREE.HemisphereLight(0xbcd4ff, 0x4a5a3a, 1); + private hemi = new THREE.HemisphereLight(0xbcd4ff, 0x8a7a60, 1); private fill = new THREE.AmbientLight(0xffffff, 0); private land = new Land(); private fx = new Fx(); @@ -1012,10 +1019,11 @@ export class City { cam.y += 4; } } - // Haze that softens the hills and fades the mountains, without hiding them. + // Haze that sets the city off from the woods behind it and fades the + // mountains, without hiding them. const fog = this.scene.fog as THREE.Fog; - fog.near = Math.max(650, dist * 3.2); - fog.far = Math.max(5200, dist * 14); + fog.near = Math.max(300, dist * 1.5); + fog.far = Math.max(3000, dist * 11); } // ---- light ---------------------------------------------------------------- @@ -1035,14 +1043,17 @@ export class City { this.shadowDir = dir.clone(); this.shadowsDirty(); } - this.sunLight.intensity = THREE.MathUtils.lerp(0.35, 3.2, d.day); - this.sunLight.color.setHex(d.day > 0.02 ? 0xffffff : 0x9fb4ff).lerp(new THREE.Color(0xffa860), d.golden * 0.7); - this.hemi.intensity = THREE.MathUtils.lerp(0.25, 1.1, d.day); - this.hemi.color.setHex(0xbcd4ff).lerp(new THREE.Color(0x2a3560), d.night); + // Warm sun against cool sky light; late in the day the sun turns orange + // and the shade lilac, so the hour reads at a glance. + this.sunLight.intensity = THREE.MathUtils.lerp(0.35, 4.4, d.day); + this.sunLight.color.setHex(d.day > 0.02 ? 0xfff2e0 : 0x9fb4ff).lerp(new THREE.Color(0xff8a3a), d.golden); + this.hemi.intensity = THREE.MathUtils.lerp(0.25, 2.2, d.day); + this.hemi.color.setHex(0xb0c8ff).lerp(new THREE.Color(0x2a3560), d.night).lerp(new THREE.Color(0x9088c0), d.golden * 0.5); + this.hemi.groundColor.setHex(0x8a7a60).lerp(new THREE.Color(0x101418), d.night); this.scene.environmentIntensity = THREE.MathUtils.lerp(0.15, 0.6, d.day); - this.fill.intensity = this.scene.environment ? 0 : FILL * this.scene.environmentIntensity; + this.fill.intensity = this.scene.environment ? 0 : FILL * this.scene.environmentIntensity * THREE.MathUtils.lerp(1, DAY_FILL, d.day); for (const m of this.glossy) if (m.envMap === this.roomEnv) m.envMapIntensity = this.scene.environmentIntensity; - this.renderer.toneMappingExposure = THREE.MathUtils.lerp(0.75, 0.6, d.day); + this.renderer.toneMappingExposure = THREE.MathUtils.lerp(0.75, 0.72, d.day) + d.golden * 0.15; (this.stars.material as THREE.PointsMaterial).opacity = d.night; const fog = new THREE.Color(0xc4d4e4).lerp(new THREE.Color(0xe0a070), d.golden * 0.6).lerp(new THREE.Color(0x0c1020), d.night); (this.scene.fog as THREE.Fog).color.copy(fog); From 21ce212a5bd4844609df45cee4090e9b8b62ad4b Mon Sep 17 00:00:00 2001 From: Sagyam Thapa Date: Sat, 7 Nov 2026 10:39:14 +0545 Subject: [PATCH 2/2] City: depth pre-pass and backlight for leaves A crown is many layers of alpha-tested cards, and with a cut-out the GPU lights every layer before the depth test drops the hidden ones. The leaves now draw a cut-out-only depth pass first, then the lit pass with no depth writes, so each pixel is lit once. The pre-pass repeats the woods' near-only vertex step (userData.vertex), or far crowns showed the sky through (blooming white blobs), and sits a hair behind (polygon offset). Leaves also let light through: a crown glows against a low sun. GPU (bench-app, headless): Hamlet fixed view 19.6 -> 18.3 ms, Hamlet opening view 20.8 -> 19.5; metropolis unchanged (~30), its trees being mostly impostors. &leaves=0 turns both off for comparison. Co-Authored-By: Claude Opus 5.5 --- docs/APP.md | 8 +++++- docs/DEVELOPMENT.md | 2 +- docs/ROADMAP.md | 5 +++- ui/src/city/impostor.ts | 6 ++-- ui/src/city/real.ts | 61 +++++++++++++++++++++++++++++++++++------ ui/src/city/scene.ts | 43 ++++++++++++++++++++++++++++- 6 files changed, 110 insertions(+), 15 deletions(-) diff --git a/docs/APP.md b/docs/APP.md index c2d66b4..f4b7cc6 100644 --- a/docs/APP.md +++ b/docs/APP.md @@ -139,6 +139,11 @@ behind `&opt=N` before they move to `scene.ts`. What was learned: - **Draw only when something changes.** A still city draws no frames; a hidden window none at all; an unfocused one ambient motion at `DIM_FPS` with traffic and auto-rotate stopped, moments still at full rate. +- **Leaves draw their depth first** (`leafPass()` in `scene.ts`): a cheap + cut-out-only pass, then the lit leaves without depth writes, so each pixel + is lit once, not once per leaf layer. Hamlet ~1.3 ms of ~20; nothing where + impostors hold most trees. The pre-pass must repeat any vertex step + (`userData.vertex`) or holes show the sky. - **Shadow filter**: a fixed 2x2 grid of hardware-PCF taps (`shadow.ts`) looks like three's noisy 5-tap disc and saves ~1.5 ms; one tap shows jagged leaf shadows. @@ -166,7 +171,8 @@ Open items are in [ROADMAP.md](ROADMAP.md#rendering). - American and European styles mixed; real-world sizes per model. - **Daylight from a warm sun and a sky hemisphere**, not a flat ambient: an ambient lights every face alike and flattened the models (`light()` in - `scene.ts`). Late in the day the sun turns orange and the shade lilac. + `scene.ts`). Late in the day the sun turns orange and the shade lilac; + leaves let light through (`foliage()` in `real.ts`). - Landmarks include civic buildings, squares, parks and monuments on half or whole blocks; the master plan reserves their ground. - 30 fps for an end-game city on a weak iGPU is acceptable. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index b1df79e..cbe396b 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -39,7 +39,7 @@ bunx tauri build --no-bundle # binary in target/release/deepwork-ui `session`, `stopwatch`, `surrender`, `ruins`, `desk`, `nearmiss`, `levelup`, `inverted`. `&hour=16` fixes the time of day; `&quality=low|medium|high` and `&taps=1|4|5` override the quality preset and -shadow filter. In the console, +shadow filter; `&leaves=0` turns off the leaf pre-pass and backlight. In the console, `city.demo("collapse" | "build" | "damage" | "graduate")` plays a transition. ### Models diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5a27192..b5270ef 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -58,7 +58,10 @@ with a root shell open in another terminal for the PAM checks. - [ ] Measure in the real Tauri window (WebKitGTK is usually slower than Chromium) with `VITE_FPS=1`. Target: 30 fps end-game city on a 610M-class iGPU. -- [ ] Hamlet trees cost ~3.5 ms of its ~21 ms GPU: cut-out leaves at MSAA. +- [ ] Trees: the leaf pre-pass took ~1.3 ms off the Hamlet's trees. Next, + the far woods: a lumpy opaque canopy mesh, or octahedral impostors + (the crossed cards show from the city's downward view and look like + flat copper sheets under a low sun). - [ ] Instancing: one InstancedMesh per piece and material instead of merged `Parts`. - [ ] Shrink the pack further: the AO bakes are ~90 MB of it, one channel diff --git a/ui/src/city/impostor.ts b/ui/src/city/impostor.ts index f89616a..dd4974e 100644 --- a/ui/src/city/impostor.ts +++ b/ui/src/city/impostor.ts @@ -222,11 +222,13 @@ export function nearOnly(m: THREE.Material): THREE.Material { const c = m.clone(); const prev = m.onBeforeCompile.bind(m); const key = m.customProgramCacheKey(); + const clip = `if (distance(cameraPosition, (modelMatrix * instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0)).xyz) >= ${IMPOSTOR_NEAR.toFixed(1)}) gl_Position = vec4(0.0, 0.0, 2.0, 1.0);`; c.onBeforeCompile = (sh, r) => { prev(sh, r); - sh.vertexShader = sh.vertexShader.replace("#include ", `#include \nif (distance(cameraPosition, (modelMatrix * instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0)).xyz) >= ${IMPOSTOR_NEAR.toFixed(1)}) gl_Position = vec4(0.0, 0.0, 2.0, 1.0);`); + sh.vertexShader = sh.vertexShader.replace("#include ", `#include \n${clip}`); }; c.customProgramCacheKey = () => `${key}:near-only`; - c.userData = { ...m.userData }; + // vertex: the leaves' depth pre-pass (real.ts, leafDepth()) must drop the same trees. + c.userData = { ...m.userData, vertex: clip }; return c; } diff --git a/ui/src/city/real.ts b/ui/src/city/real.ts index 42decc4..a194027 100644 --- a/ui/src/city/real.ts +++ b/ui/src/city/real.ts @@ -1503,27 +1503,70 @@ export function setSignals(t: number, night = 0) { } } +/** leaves: false with `&leaves=0`, for comparing the leaf shading and depth pre-pass with what came before. */ +export const LEAF_OPT = new URLSearchParams(location.search).get("leaves") !== "0"; + +/** + * A card seen edge-on smears its leaves into streaks and gives the crown away + * as flat sheets: thin it out as it turns edge-on (its true face, from the + * screen-space derivatives; the normals are bent). Shared by the leaves and + * their depth pre-pass, which must cut out exactly the same pixels. + */ +const THIN = "vec3 cardN = normalize(cross(dFdx(vViewPosition), dFdy(vViewPosition)));\ndiffuseColor.a *= smoothstep(0.05, 0.25, abs(dot(cardN, normalize(vViewPosition))));\n#include "; + +/** + * backlight: a leaf lets light through. Each light's (shadowed) light adds + * some of the leaf's colour on the side away from it, most when the eye + * looks towards the light through the crown, so trees glow against a low sun. + */ +const BACKLIGHT = `RE_Direct( directLight, geometryPosition, geometryNormal, geometryViewDir, geometryClearcoatNormal, material, reflectedLight ); + reflectedLight.directDiffuse += directLight.color * diffuseColor.rgb * vec3( 1.0, 1.05, 0.7 ) * ( 0.18 * max( 0.0, - dot( geometryNormal, directLight.direction ) ) + 0.55 * pow( max( 0.0, dot( - geometryViewDir, directLight.direction ) ), 5.0 ) );`; + /** * Leaf cards: the cluster texture alpha-tested, both sides lit as the front - * (the normals are bent out from the crown, see bendLeaves), baked AO. + * (the normals are bent out from the crown, see bendLeaves), baked AO, + * backlit. `userData.foliage` marks them for the depth pre-pass (leafDepth()). */ function foliage(name: string, color: number, ao: THREE.Texture): THREE.MeshStandardMaterial { const m = new THREE.MeshStandardMaterial({ map: tex(`${name}_col`), color, alphaTest: 0.45, side: THREE.DoubleSide, roughness: 0.78, aoMap: ao, aoMapIntensity: 0.55 }); + m.userData.foliage = true; m.onBeforeCompile = (sh) => { sh.fragmentShader = sh.fragmentShader .replace("#include ", THREE.ShaderChunk.normal_fragment_begin.replace("normal *= faceDirection;", "")) - // A card seen edge-on smears its leaves into streaks and gives the - // crown away as flat sheets: thin it out as it turns edge-on (its true - // face, from the screen-space derivatives; the normals are bent). - .replace( - "#include ", - "vec3 cardN = normalize(cross(dFdx(vViewPosition), dFdy(vViewPosition)));\ndiffuseColor.a *= smoothstep(0.05, 0.25, abs(dot(cardN, normalize(vViewPosition))));\n#include ", - ); + .replace("#include ", THIN); + if (LEAF_OPT) sh.fragmentShader = sh.fragmentShader.replace("#include ", THREE.ShaderChunk.lights_fragment_begin.replaceAll("RE_Direct( directLight, geometryPosition, geometryNormal, geometryViewDir, geometryClearcoatNormal, material, reflectedLight );", BACKLIGHT)); }; - m.customProgramCacheKey = () => "foliage"; + m.customProgramCacheKey = () => (LEAF_OPT ? "foliage+" : "foliage"); return m; } +const leafDepths = new WeakMap(); + +/** + * leaves: the depth pre-pass for a leaf material (scene.ts, leafPass()): + * no colour, no light, only its cut-out (and any vertex step it carries in + * `userData.vertex`, see nearOnly()), so the lit pass after it shades + * each pixel once instead of once for every leaf layer behind it. + */ +export function leafDepth(m: THREE.MeshStandardMaterial): THREE.MeshBasicMaterial { + let d = leafDepths.get(m); + if (!d) { + d = new THREE.MeshBasicMaterial({ map: m.map, alphaTest: m.alphaTest, side: m.side, colorWrite: false, alphaToCoverage: true, fog: false }); + // The two shaders round a vertex's depth a hair apart, so far crowns + // failed the lit pass and showed the sky through: push the pre-pass back. + d.polygonOffset = true; + d.polygonOffsetFactor = 1; + d.polygonOffsetUnits = 2; + d.onBeforeCompile = (sh) => { + sh.vertexShader = "varying vec3 vViewPosition;\n" + sh.vertexShader.replace("#include ", `#include \nvViewPosition = - mvPosition.xyz;\n${m.userData.vertex ?? ""}`); + sh.fragmentShader = "varying vec3 vViewPosition;\n" + sh.fragmentShader.replace("#include ", THIN); + }; + d.customProgramCacheKey = () => (m.userData.vertex ? "leafdepth:near-only" : "leafdepth"); + leafDepths.set(m, d); + } + return d; +} + /** * A tiling ground material (grass, paving, asphalt) mapped by world x / z, * so boxes of any size share it without UVs of their own; `tile` in metres. diff --git a/ui/src/city/scene.ts b/ui/src/city/scene.ts index 6673027..188c530 100644 --- a/ui/src/city/scene.ts +++ b/ui/src/city/scene.ts @@ -21,7 +21,7 @@ import { Fx } from "./fx"; import { IMPOSTOR_NEAR, Impostors, loaded } from "./impostor"; import { loadKit } from "./kit"; import { thumbnail } from "./thumbs"; -import { groundMat, hasReal, interiorUniforms, loadReal, realMat, realPiece, setRealEnv, setSignals, setSprayScale, setWaterTime, useModels } from "./real"; +import { groundMat, hasReal, interiorUniforms, LEAF_OPT, leafDepth, loadReal, realMat, realPiece, setRealEnv, setSignals, setSprayScale, setWaterTime, useModels } from "./real"; import { modelsStatus, type ModelsStatus } from "../models"; import { Traffic } from "./traffic"; import { Land } from "./land"; @@ -112,6 +112,8 @@ export class City { /** Materials that took the environment light in prepare(); their intensity follows the day. */ private glossy = new Set(); private prepared = new WeakSet(); + /** Leaf materials with a depth pre-pass (leafPass()): they write no depth in the main pass. */ + private leafMats = new Set(); /** Cut-out textures the shadow map waits for. */ private shadowWait = new Set(); private shadowDir: THREE.Vector3 | null = null; @@ -1174,9 +1176,11 @@ export class City { * and trees would cast the shadows of bare branches). */ private prepare(root: THREE.Object3D) { + const leafy: THREE.Mesh[] = []; root.traverse((o) => { const mesh = o as THREE.Mesh; if (!mesh.isMesh) return; + if (LEAF_OPT && !mesh.userData.leafPass && (Array.isArray(mesh.material) ? mesh.material : [mesh.material]).some((m) => m.userData.foliage)) leafy.push(mesh); for (const m of Array.isArray(mesh.material) ? mesh.material : [mesh.material]) { const s = m as THREE.MeshStandardMaterial; if (mesh.castShadow && m.alphaTest > 0 && s.map && !loaded(s.map)) this.shadowWait.add(s.map); @@ -1194,9 +1198,42 @@ export class City { } } }); + for (const mesh of leafy) this.leafPass(mesh); this.shadowsDirty(); } + /** + * leaves: a crown is many layers of alpha-tested cards, and with a cut-out + * the GPU shades every layer before the depth test throws the hidden ones + * away. So the leaves draw twice: a child that writes only their depth, + * first (renderOrder), then the lit leaves, which write no depth in the + * main pass (frame()) and so are tested before shading: each pixel is lit + * once. The child shares the geometry and instances, and hides with them. + */ + private leafPass(mesh: THREE.Mesh) { + mesh.userData.leafPass = true; + const hide = new THREE.MeshBasicMaterial({ visible: false }); + const depth = (m: THREE.Material) => { + if (!m.userData.foliage) return hide; + this.leafMats.add(m); + return leafDepth(m as THREE.MeshStandardMaterial); + }; + const mats = Array.isArray(mesh.material) ? mesh.material.map(depth) : depth(mesh.material); + const src = mesh as THREE.InstancedMesh; + let pre: THREE.Mesh; + if (src.isInstancedMesh) { + const inst = new THREE.InstancedMesh(src.geometry, mats, src.count); + inst.instanceMatrix = src.instanceMatrix; + pre = inst; + } else pre = new THREE.Mesh(mesh.geometry, mats); + pre.renderOrder = mesh.renderOrder - 1; + pre.frustumCulled = mesh.frustumCulled; + pre.castShadow = pre.receiveShadow = false; + pre.userData.leafPass = true; + pre.raycast = () => {}; + mesh.add(pre); + } + /** * prewarm: compiles every shader and uploads every texture now, with * everything shown, instead of the first time it comes into view: a shader @@ -1446,7 +1483,11 @@ export class City { } this.drawnAt = now; this.bench?.begin(); + // leaves: their depth is already drawn (leafPass()); only for this scene's + // pass, as thumbnails and impostors draw the same materials without one. + for (const m of this.leafMats) m.depthWrite = false; this.composer.render(dt); + for (const m of this.leafMats) m.depthWrite = true; this.bench?.end(); } }