Shadows land on the building, and the sky layers stop repeating themselves
**Shadows were the right size and pointed at nothing.** Last round fixed the missing `updateProjectionMatrix()`, so the frustum finally became the size every caller asks for — but nothing aimed it, and `sun.target` sits at the world origin. A pack's origin is the **north-west corner of its slab**, so for lumbridge-hq the box was off-centre by half the building: 14.4 m of a 48 m plate, about a third of the floor, fell outside the frustum and neither cast nor received. Invisible while three's broken ±5 default made shadows useless everywhere; obvious the moment they started working. `SceneKitOptions` takes a `shadowTarget` now, both callers pass one, and the light's target is added to the scene — which is the part that actually matters, because `LightShadow.updateMatrices` reads `target.matrixWorld` and an unparented `Object3D` is never reached by the traversal that updates it. The sun is also placed relative to the target rather than the origin, so light-to-target is exactly `sunDistance` for every direction, which is the invariant each caller's `shadowNear`/`shadowFar` were chosen against. **`flights.ts` could not be tested, and that is why it was untested.** It used a TypeScript parameter property — the one piece of TS syntax that *emits code* rather than annotating a type — so Node's type stripping refused the whole module. The bundler never cared, so nobody found out until the first `node --test` file tried to import it. The module carrying the worst bug this project has shipped was, by construction, the one module that could not have a test. It has eleven now, including one that fails if the live-aircraft repeat-skip is removed. **Robots are on the plan panel** — a turned marker with a bow for heading, in the one hue left that is neither the people-blue nor the camera-amber. Review findings cleared across the four new sky/robot modules: a real 24 mm void at the ankle and an 8 mm hole through each forearm, a per-frame allocation in the robot heading picker, a per-frame sort in the starlink ranking, `uTime` growing unbounded until the cloud breath quantises, and `DAY_REFERENCE`'s derivation which did not reproduce. `createStarlinkMeshLayer` now takes a **board** radius — the same unit its sibling takes — instead of a dome radius with nothing in the types to tell them apart. That is the exact confusion that has already caused one real bug here. `DOME_RADIUS_FACTOR` has one owner and is imported, not copied: the points and the meshes must be on the same dome or a satellite that grows geometry also jumps. Several comments were wrong rather than merely stale — a fabricated claim about `Object3D.clone`, a fabricated attribution to `Plan`, an inverted `DoubleSide` argument, a triangle ledger citing a function that no longer exists, and a defensive-call rationale that contradicted the paragraph above it. In a codebase where the comments are the design record, those are defects. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+78
-7
@@ -90,10 +90,37 @@ export interface SceneKitOptions {
|
||||
shadowFar?: number;
|
||||
shadowBias?: number;
|
||||
/**
|
||||
* How far along its direction the sun is placed. A `LightingState` carries a
|
||||
* unit direction and no distance, because distance is a fact about the scale
|
||||
* of the scene — 94 m per unit outdoors, 1 m per unit indoors — and not about
|
||||
* where the sun is.
|
||||
* What the shadow box is centred on, in scene units. Defaults to the origin,
|
||||
* which is almost never where the thing being lit actually is.
|
||||
*
|
||||
* A `shadowExtent` says how *big* the box is; it says nothing about where.
|
||||
* three centres a directional light's shadow camera on `light.target`, and a
|
||||
* fresh `DirectionalLight` targets a brand-new `Object3D` sitting at the
|
||||
* world origin — so without this every caller got a correctly-sized box in
|
||||
* the wrong place, and the two consumers here both have their origin off to
|
||||
* one side of what they want lit:
|
||||
*
|
||||
* - An office pack's origin is the **north-west corner of its slab**, not its
|
||||
* middle. `lumbridge-hq` is 48 x 18 m against `shadowExtent: max(8, span *
|
||||
* 0.7)` = ±33.6 m, so a box on the origin covered x ∈ [-33.6, 33.6] of a
|
||||
* building occupying x ∈ [0, 48]: the eastern 14.4 m — call it a third of
|
||||
* the floor plate — fell outside the frustum entirely and neither cast a
|
||||
* shadow nor received one. Half the box was spent on the empty ground west
|
||||
* of the building.
|
||||
* - Scene space for a city is centred on `city.center`, and the comment on
|
||||
* `boardRadius` in `scene.ts` already records that the Bay Area board runs
|
||||
* forty kilometres down the peninsula from there. Same failure, one order
|
||||
* of magnitude up.
|
||||
*
|
||||
* Pass the centre of what you want shadowed: `plan.bounds.center` for an
|
||||
* office, the mid-point of the projected board for a city.
|
||||
*/
|
||||
shadowTarget?: THREE.Vector3;
|
||||
/**
|
||||
* How far along its direction the sun is placed, **from `shadowTarget`**. A
|
||||
* `LightingState` carries a unit direction and no distance, because distance
|
||||
* is a fact about the scale of the scene — 94 m per unit outdoors, 1 m per
|
||||
* unit indoors — and not about where the sun is.
|
||||
*/
|
||||
sunDistance?: number;
|
||||
/** Flight rate, in fractions of the flight per second. */
|
||||
@@ -263,9 +290,33 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
|
||||
* house, somewhere near the origin. The office asks for ±34 m and got ±5 m.
|
||||
*/
|
||||
sun.shadow.camera.updateProjectionMatrix();
|
||||
|
||||
/**
|
||||
* One update now, so a reader that recomputes for itself sees the right pose.
|
||||
*
|
||||
* This is **not** what makes the shadow correct — the paragraph above is: the
|
||||
* target has to be *in the scene* so `Object3D.updateMatrixWorld`'s traversal
|
||||
* reaches it, and `WebGLRenderer.render` runs that traversal before
|
||||
* `shadowMap.render()` on every frame. That is the whole mechanism.
|
||||
*
|
||||
* What this line buys is narrower and worth being honest about. Nothing that
|
||||
* reads `sun.shadow.camera` before the first render learns anything from it —
|
||||
* three only writes that camera's pose inside `LightShadow.updateMatrices`,
|
||||
* which runs during a render. It matters to a reader that recomputes from the
|
||||
* target itself: a `DirectionalLightHelper`, or a manual
|
||||
* `sun.shadow.updateMatrices(sun)` in a capture pass.
|
||||
*/
|
||||
const shadowTarget = new THREE.Vector3();
|
||||
if (options.shadowTarget) shadowTarget.copy(options.shadowTarget);
|
||||
sun.target.position.copy(shadowTarget);
|
||||
|
||||
const hemisphere = new THREE.HemisphereLight(0xffffff, 0x808080, 1);
|
||||
const ambient = new THREE.AmbientLight(0xffffff, 0.3);
|
||||
scene.add(sun, hemisphere, ambient);
|
||||
scene.add(sun, sun.target, hemisphere, ambient);
|
||||
// Belt and braces for anything that reads the shadow camera before the first
|
||||
// render — a capture pass, a debug helper — where the renderer's own
|
||||
// traversal has not happened yet. After that, the traversal owns it.
|
||||
sun.target.updateMatrixWorld();
|
||||
|
||||
const sunDirection = new THREE.Vector3();
|
||||
let sky: THREE.Texture | null = null;
|
||||
@@ -278,7 +329,24 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
|
||||
// A zero direction would put the sun inside the ground and black the scene
|
||||
// out; leaving it where it was is the kinder failure.
|
||||
if (sunDirection.lengthSq() > 0) {
|
||||
sun.position.copy(sunDirection.normalize().multiplyScalar(sunDistance));
|
||||
/*
|
||||
* `sunDistance` out from the **target**, not from the origin.
|
||||
*
|
||||
* A directional light's position is not physical — the shading only reads
|
||||
* `position - target` as a direction — but the shadow camera *is* placed
|
||||
* at it, and its `near`/`far` are measured from there along the view
|
||||
* axis. Off the origin those two facts fight: `officeScene.ts` asks for
|
||||
* `sunDistance: max(24, span * 1.4)`, so on a 12 m studio the sun sits
|
||||
* 24 units from the origin while the slab centre it is aimed at can be
|
||||
* 8 m away in some other direction — a light that is beside or behind the
|
||||
* building rather than above it, with the near plane cutting into the
|
||||
* geometry it is supposed to be shadowing.
|
||||
*
|
||||
* Anchoring to the target makes light-to-target exactly `sunDistance`
|
||||
* whatever the direction, which is the invariant every caller's
|
||||
* `shadowNear`/`shadowFar` was picked against.
|
||||
*/
|
||||
sun.position.copy(sunDirection.normalize().multiplyScalar(sunDistance)).add(shadowTarget);
|
||||
}
|
||||
sun.color.setHex(state.sun.color);
|
||||
sun.intensity = state.sun.intensity;
|
||||
@@ -531,7 +599,10 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
|
||||
dom.style.cursor = "";
|
||||
picking = null;
|
||||
controls.dispose();
|
||||
scene.remove(sun, hemisphere, ambient);
|
||||
// `sun.target` was added as a scene child in its own right, so removing
|
||||
// the light does not take it with it — a kit torn down and rebuilt would
|
||||
// otherwise leave one empty Object3D in the scene per cycle.
|
||||
scene.remove(sun, sun.target, hemisphere, ambient);
|
||||
sun.dispose();
|
||||
hemisphere.dispose();
|
||||
ambient.dispose();
|
||||
|
||||
Reference in New Issue
Block a user