Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/APP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -164,6 +169,10 @@ 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;
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.
Expand Down
2 changes: 1 addition & 1 deletion docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,14 @@ 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
stored as BC7; BC4 (RGTC) would halve them.
- [ ] Retune lighting: it is still tuned for the flat kit.

## Packaging

Expand Down
6 changes: 4 additions & 2 deletions ui/src/city/impostor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <project_vertex>", `#include <project_vertex>\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 <project_vertex>", `#include <project_vertex>\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;
}
61 changes: 52 additions & 9 deletions ui/src/city/real.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <alphatest_fragment>";

/**
* 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 <normal_fragment_begin>", 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 <alphatest_fragment>",
"vec3 cardN = normalize(cross(dFdx(vViewPosition), dFdy(vViewPosition)));\ndiffuseColor.a *= smoothstep(0.05, 0.25, abs(dot(cardN, normalize(vViewPosition))));\n#include <alphatest_fragment>",
);
.replace("#include <alphatest_fragment>", THIN);
if (LEAF_OPT) sh.fragmentShader = sh.fragmentShader.replace("#include <lights_fragment_begin>", 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<THREE.Material, THREE.MeshBasicMaterial>();

/**
* 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 <project_vertex>", `#include <project_vertex>\nvViewPosition = - mvPosition.xyz;\n${m.userData.vertex ?? ""}`);
sh.fragmentShader = "varying vec3 vViewPosition;\n" + sh.fragmentShader.replace("#include <alphatest_fragment>", 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.
Expand Down
80 changes: 66 additions & 14 deletions ui/src/city/scene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -105,6 +112,8 @@ export class City {
/** Materials that took the environment light in prepare(); their intensity follows the day. */
private glossy = new Set<THREE.MeshStandardMaterial>();
private prepared = new WeakSet<THREE.Material>();
/** Leaf materials with a depth pre-pass (leafPass()): they write no depth in the main pass. */
private leafMats = new Set<THREE.Material>();
/** Cut-out textures the shadow map waits for. */
private shadowWait = new Set<THREE.Texture>();
private shadowDir: THREE.Vector3 | null = null;
Expand All @@ -120,7 +129,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();
Expand Down Expand Up @@ -1012,10 +1021,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 ----------------------------------------------------------------
Expand All @@ -1035,14 +1045,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);
Expand Down Expand Up @@ -1163,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);
Expand All @@ -1183,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
Expand Down Expand Up @@ -1435,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();
}
}
Expand Down
Loading