The office keeps its lights on, and something walks around under them
**Lights.** A sited office follows the real sun, and the real sun spends
half its time below the horizon — which was producing a technically
correct and completely useless picture: an unlit floor plate at midnight
in a building whose whole premise is that you can see who is at which
desk. `luminaires.ts` brings the diffusers up as the sun goes down and
reports one scalar for how much interior light there is; `withHouseLights`
adds it to the rig. CONTRACT §4's rule that a fitting emits no light is
kept in full — nothing here is a light source, and the rig still has one
owner.
**And they notice you.** A fitting within four metres of somebody walking
underneath brightens and fades back as they leave, which is what an
occupancy-sensed floor actually does at night. They are one `InstancedMesh`
sharing one material, so `emissiveIntensity` cannot vary between them —
`instanceColor` can, but three multiplies it into the diffuse term only, so
six lines of `onBeforeCompile` carry it into the emissive term as well. The
alternative was one mesh per fitting: forty draw calls of ceiling in a
building that spends about twenty on everything.
**Optimus.** A posable Gen-3 humanoid — eleven articulating joints, pale
shells over a dark frame, a black visor — with a walk cycle driven by
*distance travelled* rather than wall-clock, so the feet do not slide when
a robot slows down. Two per floor, derived from the pack's levels, so the
two-storey tower gets four and the hangar gets two without either pack
knowing robots exist. They wander between reachable points using
`Plan.blocked` — the collider the wall split already produces — and they
are deliberately **not** gated on `depth`: the build-time-exclusion rule is
about occupancy, and a robot is nobody.
**Starlinks stop being pixels.** The sixty-four nearest the centre of view
grow real geometry — a flat bus with ONE large solar array, which is the
actual signature and the thing everybody draws symmetrically and wrong —
fading in so there is no pop where a point becomes a mesh. Two draw calls.
The sun for their attitude comes from `solar.ts` and not from the rig,
because `atmosphere.ts` floors the light direction to keep the shadow
camera usable, and a sun ten degrees *down* is exactly the dusk geometry
that makes a pass visible.
**Aircraft** are airliners now — swept wings, nacelles, a fin — instead of
an arrowhead, still one shared geometry facing +Z as `flights.ts` requires.
**Clouds** drift over the board, driven by observed cover, lit by the rig
rather than by themselves.
Four modules were built by subagents and reviewed by another; every one
came back `needs-work` and the reviews were right. Fixed before wiring:
- The walk cycle's arms were a quarter cycle out of step with its legs —
the legs are cosine-shaped and the arms were on `sin`, so at the
instant the left leg reached full forward the left shoulder was at dead
neutral. Uncanny, and hard to name until it is pointed at.
- Every Optimus shell used a `roundedBox` radius of 0.12–0.22, which that
primitive turns into a near-circular cross-section — the figure was
built out of lozenges, not panels. The rest of the library uses
0.02–0.09.
- The cloud material was `transparent` + `DoubleSide` without
`forceSinglePass`, so three rendered it twice per frame *and* bumped
`material.version` on each pass — rebuilding the program cache key
forever, on the one layer that is fill-rate bound.
- `starlinkMesh.dispose()` freed the geometries but not the
`InstancedMesh`es, orphaning their instance buffers on every city
switch.
- The airliner's tailplane roots sat outside the tail cone and hung in
free air over most of their chord.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -95,6 +95,80 @@ export function officeDaylight(state: LightingState, site: OfficeSite): Lighting
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The colour of the light a building makes for itself.
|
||||
*
|
||||
* Warm, and warmer than daylight on purpose: an office at night is lit at
|
||||
* something like 3500 K against a 5500 K sun, and the shift is most of what
|
||||
* makes an interior at night read as *interior* rather than as a badly exposed
|
||||
* afternoon. It is also what stops the night rig looking like a dimmer switch
|
||||
* on the day rig, which is what a neutral lift would give.
|
||||
*/
|
||||
const HOUSE_COLOR = 0xffe4bd;
|
||||
|
||||
/** Ambient and hemisphere added at full darkness. */
|
||||
const HOUSE_AMBIENT = 0.5;
|
||||
const HOUSE_HEMISPHERE = 0.85;
|
||||
|
||||
/**
|
||||
* Add the building's own lights to a rig that has run out of sun.
|
||||
*
|
||||
* Kept here, next to the other adaptation of a `LightingState` for an interior,
|
||||
* and kept **out** of `luminaires.ts` — that file drives the glowing panels and
|
||||
* decides how much artificial light there is, and this one applies it, because
|
||||
* CONTRACT.md §4 gives the rig one owner and two files writing lights is exactly
|
||||
* what that rule exists to prevent.
|
||||
*
|
||||
* `level` is `Luminaires.houseLevel()`: 0 in daylight, 1 once the sun is down.
|
||||
* At 0 this returns the state unchanged, so a daylit office pays nothing and
|
||||
* looks identical to before any of this existed.
|
||||
*
|
||||
* Note what is **not** touched: `sun`. The sun is where the sun is, and at
|
||||
* midnight it is below the floor contributing nothing. Interior light is
|
||||
* ambient and hemispherical because that is what a ceiling of diffusers
|
||||
* actually produces — a room lit from a hundred soft sources has almost no
|
||||
* directional term, which is why offices at night have such flat shadows.
|
||||
*/
|
||||
export function withHouseLights(state: LightingState, level: number): LightingState {
|
||||
const t = Math.min(1, Math.max(0, level));
|
||||
if (t === 0) return state;
|
||||
|
||||
return {
|
||||
...state,
|
||||
ambient: {
|
||||
// Blended toward the interior colour rather than replaced, so dusk — when
|
||||
// both are running — does not jump between two different whites.
|
||||
color: mixHex(state.ambient.color, HOUSE_COLOR, t),
|
||||
intensity: state.ambient.intensity + HOUSE_AMBIENT * t,
|
||||
},
|
||||
hemisphere: {
|
||||
sky: mixHex(state.hemisphere.sky, HOUSE_COLOR, t),
|
||||
// The floor of a lit office bounces its own light back up, and leaving the
|
||||
// ground term at the night sky's near-black is what makes a figure's legs
|
||||
// vanish while their head is lit.
|
||||
ground: mixHex(state.hemisphere.ground, HOUSE_COLOR, t * 0.6),
|
||||
intensity: state.hemisphere.intensity + HOUSE_HEMISPHERE * t,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Blend two packed 0xRRGGBB colours.
|
||||
*
|
||||
* Per channel on the raw bytes, which is not a perceptual blend and does not
|
||||
* need to be: both ends are near-white and the path between them stays there.
|
||||
* Doing it by hand avoids constructing two `THREE.Color`s per frame in a module
|
||||
* that deliberately imports no three.js.
|
||||
*/
|
||||
function mixHex(from: number, to: number, t: number): number {
|
||||
const mix = (shift: number) => {
|
||||
const a = (from >> shift) & 0xff;
|
||||
const b = (to >> shift) & 0xff;
|
||||
return Math.round(a + (b - a) * t) & 0xff;
|
||||
};
|
||||
return (mix(16) << 16) | (mix(8) << 8) | mix(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate a world-frame direction into the building's frame.
|
||||
*
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Turning the office lights on, and letting them notice you.
|
||||
*
|
||||
* `src/assets/office/lighting.ts` says plainly that a fitting emits no light:
|
||||
* lighting has one owner (CONTRACT.md §4) and a hundred `PointLight`s is both
|
||||
* the wrong owner and, past about four shadow casters, the end of the frame
|
||||
* budget. That rule is kept here in full. **Nothing in this file is a light
|
||||
* source.** What it does is make the *diffusers* — the glowing panel of each
|
||||
* fitting, which is the thing you actually look at — brighter or dimmer, and
|
||||
* hand the scene a single scalar for how much interior light the rig should
|
||||
* add on top.
|
||||
*
|
||||
* ### Why the room needs this at all
|
||||
*
|
||||
* A sited office follows the real sun (`daylight.ts`), and the real sun spends
|
||||
* half its time below the horizon. Before this, that produced a technically
|
||||
* correct and completely useless picture: an unlit floor plate at midnight, with
|
||||
* ceiling fittings drawn as pale grey discs, in a building whose entire premise
|
||||
* is that you can see who is at which desk. Every office on earth solves this
|
||||
* the same way and it is not subtle — the lights are on.
|
||||
*
|
||||
* ### Two effects, one of which is the interesting one
|
||||
*
|
||||
* **House lights** are the baseline: as the sun goes down, the diffusers come up
|
||||
* and the caller lifts the ambient and hemisphere terms with a warm interior
|
||||
* colour. That is a whole-building state and a single number.
|
||||
*
|
||||
* **Presence** is the per-fitting one. A fitting within `PRESENCE_RADIUS` of
|
||||
* somebody walking underneath brightens further, and fades back when they leave.
|
||||
* Real buildings genuinely do this — occupancy sensors on a floor at night are
|
||||
* why a lit corridor follows you through an empty office — so it is not a
|
||||
* flourish, it is the behaviour. It is also the cheapest interesting thing in
|
||||
* the scene: no lights, no raycasts, one distance test per fitting per frame
|
||||
* against a handful of walkers.
|
||||
*
|
||||
* ### How a single fitting can be brighter than its neighbour
|
||||
*
|
||||
* They are one `InstancedMesh` sharing one material, so `emissiveIntensity`
|
||||
* cannot vary between them — it is a uniform. `instanceColor` *can*, but three
|
||||
* multiplies it into the diffuse term only, and a diffuser's whole appearance is
|
||||
* its emissive term. So `onBeforeCompile` patches six lines of the emissive
|
||||
* chunk to multiply by the instance colour as well.
|
||||
*
|
||||
* The alternative was one mesh per fitting, which is forty draw calls of ceiling
|
||||
* in a building that currently spends about twenty on everything.
|
||||
*/
|
||||
|
||||
import * as THREE from "three";
|
||||
import type { LuminaireBatch } from "./furnish.ts";
|
||||
|
||||
/**
|
||||
* How far from a fitting somebody has to be to bring it up, in metres.
|
||||
*
|
||||
* Four metres is about one structural bay, so walking a corridor brings on the
|
||||
* fitting ahead of you before you are under it and lets the one behind you fade
|
||||
* — which is what makes it read as the building responding rather than as a
|
||||
* lamp attached to a robot.
|
||||
*/
|
||||
const PRESENCE_RADIUS = 4.0;
|
||||
|
||||
/** How much brighter a fitting goes when somebody is directly under it. */
|
||||
const PRESENCE_GAIN = 1.5;
|
||||
|
||||
/**
|
||||
* How fast a fitting reaches its target brightness, per second.
|
||||
*
|
||||
* Deliberately not instant. A hard cut tracks the walker exactly and looks like
|
||||
* a bug; a slow fade reads as a real fitting warming up and, more usefully,
|
||||
* hides the fact that the trigger is a hard radius rather than a sensor cone.
|
||||
*/
|
||||
const FADE_PER_SECOND = 3.2;
|
||||
|
||||
/**
|
||||
* Below this solar elevation the lights are fully on; above the upper bound they
|
||||
* are fully off. Degrees.
|
||||
*
|
||||
* The band is civil twilight rather than a step at the horizon, because that is
|
||||
* when an occupied building actually switches over — the sun is down well before
|
||||
* anybody needs the lights, and the ramp across those six degrees is what stops
|
||||
* the whole floor changing state in one frame at sunset.
|
||||
*/
|
||||
const LIGHTS_ON_BELOW_DEG = 0;
|
||||
const LIGHTS_OFF_ABOVE_DEG = 6;
|
||||
|
||||
export interface Walker {
|
||||
position: THREE.Vector3;
|
||||
}
|
||||
|
||||
export interface Luminaires {
|
||||
/**
|
||||
* How much artificial light is in this building right now, 0..1.
|
||||
*
|
||||
* The caller adds it to the rig; this file does not, because the rig has one
|
||||
* owner. See `houseLightContribution` in `daylight.ts`.
|
||||
*/
|
||||
houseLevel(): number;
|
||||
/** Solar elevation in degrees, from the same clock everything else follows. */
|
||||
setSolarElevation(degrees: number): void;
|
||||
/** Who is walking about, so fittings can respond to them. Cheap to call often. */
|
||||
setWalkers(walkers: readonly Walker[]): void;
|
||||
tick(dt: number): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export function createLuminaires(batches: readonly LuminaireBatch[]): Luminaires {
|
||||
// Only the glowing halves respond. A fitting's housing is painted metal and
|
||||
// brightening it would make the ceiling look like it was made of lamps.
|
||||
const lit = batches.filter((b) => b.emissive);
|
||||
|
||||
/** Current and target brightness per instance, per batch. Preallocated. */
|
||||
const state = lit.map((b) => ({
|
||||
batch: b,
|
||||
current: new Float32Array(b.positions.length),
|
||||
target: new Float32Array(b.positions.length),
|
||||
}));
|
||||
|
||||
const patched = new Set<THREE.Material>();
|
||||
for (const entry of state) {
|
||||
const mesh = entry.batch.mesh;
|
||||
// `setColorAt` allocates the attribute on first use; doing it here means the
|
||||
// per-frame path only ever writes into it.
|
||||
const white = new THREE.Color(1, 1, 1);
|
||||
for (let i = 0; i < entry.batch.positions.length; i += 1) mesh.setColorAt(i, white);
|
||||
if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
|
||||
|
||||
const material = mesh.material as THREE.Material;
|
||||
if (patched.has(material)) continue;
|
||||
patched.add(material);
|
||||
/**
|
||||
* Make the instance colour reach the emissive term.
|
||||
*
|
||||
* three multiplies `vColor` into `diffuseColor` and stops there, which for
|
||||
* an object whose appearance is almost entirely emissive means
|
||||
* `instanceColor` does very nearly nothing. The chunk below runs after
|
||||
* `emissivemap_fragment`, which is where `totalEmissiveRadiance` has its
|
||||
* final value.
|
||||
*
|
||||
* Guarded on `USE_INSTANCING_COLOR` so the same shared material stays
|
||||
* correct for any non-instanced user of the `lightDiffuser` role.
|
||||
*/
|
||||
material.onBeforeCompile = (shader) => {
|
||||
shader.fragmentShader = shader.fragmentShader.replace(
|
||||
"#include <emissivemap_fragment>",
|
||||
`#include <emissivemap_fragment>
|
||||
#ifdef USE_INSTANCING_COLOR
|
||||
totalEmissiveRadiance *= vColor.rgb;
|
||||
#endif`,
|
||||
);
|
||||
};
|
||||
material.needsUpdate = true;
|
||||
}
|
||||
|
||||
let house = 0;
|
||||
let walkers: readonly Walker[] = [];
|
||||
const scratch = new THREE.Color();
|
||||
|
||||
return {
|
||||
houseLevel: () => house,
|
||||
|
||||
setSolarElevation(degrees) {
|
||||
const span = LIGHTS_OFF_ABOVE_DEG - LIGHTS_ON_BELOW_DEG;
|
||||
const t = (degrees - LIGHTS_ON_BELOW_DEG) / span;
|
||||
house = 1 - Math.min(1, Math.max(0, t));
|
||||
},
|
||||
|
||||
setWalkers(next) {
|
||||
walkers = next;
|
||||
},
|
||||
|
||||
tick(dt) {
|
||||
// Fittings are dark in daylight and there is nothing to interpolate, so a
|
||||
// sunlit building costs one comparison rather than a pass over the ceiling.
|
||||
const step = Math.min(1, dt * FADE_PER_SECOND);
|
||||
|
||||
for (const entry of state) {
|
||||
const { positions } = entry.batch;
|
||||
let changed = false;
|
||||
|
||||
for (let i = 0; i < positions.length; i += 1) {
|
||||
const at = positions[i];
|
||||
if (!at) continue;
|
||||
|
||||
let want = house;
|
||||
if (house > 0 && walkers.length > 0) {
|
||||
let nearest = Infinity;
|
||||
for (const walker of walkers) {
|
||||
// Horizontal distance only: a fitting is on the ceiling and the
|
||||
// walker is on the floor, and including the three-metre vertical
|
||||
// gap would mean nobody ever gets close enough to trigger one.
|
||||
const dx = walker.position.x - at.x;
|
||||
const dz = walker.position.z - at.z;
|
||||
const d = Math.hypot(dx, dz);
|
||||
if (d < nearest) nearest = d;
|
||||
}
|
||||
if (nearest < PRESENCE_RADIUS) {
|
||||
// Smoothstep rather than linear, so the bright patch has a soft
|
||||
// edge instead of a visible circle travelling across the ceiling.
|
||||
const near = 1 - nearest / PRESENCE_RADIUS;
|
||||
want += house * (PRESENCE_GAIN - 1) * near * near * (3 - 2 * near);
|
||||
}
|
||||
}
|
||||
|
||||
entry.target[i] = want;
|
||||
const current = entry.current[i] ?? 0;
|
||||
const next = current + (want - current) * step;
|
||||
if (Math.abs(next - current) > 1e-4) {
|
||||
entry.current[i] = next;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) continue;
|
||||
for (let i = 0; i < positions.length; i += 1) {
|
||||
const v = entry.current[i] ?? 0;
|
||||
scratch.setScalar(v);
|
||||
entry.batch.mesh.setColorAt(i, scratch);
|
||||
}
|
||||
if (entry.batch.mesh.instanceColor) entry.batch.mesh.instanceColor.needsUpdate = true;
|
||||
}
|
||||
},
|
||||
|
||||
dispose() {
|
||||
// The meshes and materials belong to `furnish.ts` and are disposed there.
|
||||
// What is owned here is the shader patch, which has to come off or a
|
||||
// material reused by the next office keeps a compile hook pointing at a
|
||||
// scene that no longer exists.
|
||||
for (const material of patched) {
|
||||
material.onBeforeCompile = () => {};
|
||||
material.needsUpdate = true;
|
||||
}
|
||||
patched.clear();
|
||||
walkers = [];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -82,6 +82,8 @@ import { createFurnishings, type Furnishings } from "./furnish.ts";
|
||||
import { Plan, type Depth, type PlanOptions } from "./plan.ts";
|
||||
import { createPresenceLayer, type PresenceLayer, type PresencePalette } from "./presence.ts";
|
||||
import { createShell, type Shell, type WallInfo } from "./shell.ts";
|
||||
import { createLuminaires, type Luminaires, type Walker } from "./luminaires.ts";
|
||||
import { createRobotLayer, type RobotLayer, type RobotSpec } from "./robots.ts";
|
||||
import type { Office, Point2, Presence, Viewpoint } from "./types.ts";
|
||||
|
||||
// Re-exported so a caller can name the tier it is asking for without importing
|
||||
@@ -189,6 +191,17 @@ export interface OfficeSceneOptions {
|
||||
* those are the three things you actually feel.
|
||||
*/
|
||||
horizon?: { drop: number };
|
||||
/**
|
||||
* Humanoids to walk about the floor, one entry per robot.
|
||||
*
|
||||
* **Not gated on `depth`, unlike `presence`, and that asymmetry is the point.**
|
||||
* The build-time-exclusion rule in this file's header is about *occupancy* — a
|
||||
* `Presence` names a person and comes from an authenticated API, so the public
|
||||
* office must not construct one. A robot is nobody: it carries no id anybody
|
||||
* issued, no seat binding, and no data from anywhere. There is nothing to
|
||||
* withhold, so a stranger gets them too.
|
||||
*/
|
||||
robots?: readonly RobotSpec[];
|
||||
/** Defaults to false — the lid comes off, because that is the whole view. */
|
||||
showCeilings?: boolean;
|
||||
/** Fade the walls you are looking through. Defaults to true. */
|
||||
@@ -227,6 +240,26 @@ export interface OfficeScene extends StageScene {
|
||||
anchors: Map<string, THREE.Vector3>;
|
||||
setCeilingsVisible(visible: boolean): void;
|
||||
setLighting(state: LightingState): void;
|
||||
/**
|
||||
* The sun's height, in degrees, from whatever clock the app is running.
|
||||
*
|
||||
* This is what turns the lights on. It is a separate call from `setLighting`
|
||||
* and not a field on `LightingState` for the reason `scene.ts` gives for the
|
||||
* city's identical pair: a `LightingState` is a rig, and how far below the
|
||||
* horizon the sun is is a fact about the sky that the rig has already spent.
|
||||
*/
|
||||
setSolarElevation(degrees: number): void;
|
||||
/**
|
||||
* How much of the building's own light is on, 0..1, after the last
|
||||
* `setSolarElevation`. The caller adds it to the rig — see `withHouseLights`
|
||||
* in `daylight.ts`, and CONTRACT.md §4 on why this file does not.
|
||||
*/
|
||||
houseLevel(): number;
|
||||
/**
|
||||
* Who is moving about the floor, so the fittings above them can come up.
|
||||
* Cheap; call it every frame. An empty list is the normal state.
|
||||
*/
|
||||
setWalkers(walkers: readonly Walker[]): void;
|
||||
}
|
||||
|
||||
export function createOfficeScene(office: Office, options: OfficeSceneOptions): OfficeScene {
|
||||
@@ -391,6 +424,38 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
|
||||
...(options.registry ? { registry: options.registry } : {}),
|
||||
...(options.colorFor ? { colorFor: options.colorFor } : {}),
|
||||
});
|
||||
|
||||
/**
|
||||
* The ceiling, made switchable.
|
||||
*
|
||||
* Built from the furnishings rather than from the pack, because what a
|
||||
* fitting *is* has already been resolved by then: an id has been through the
|
||||
* registry's override table, and a self-hoster who pointed
|
||||
* `tera:light.troffer` at their own asset gets their fitting switched on
|
||||
* rather than a fitting nobody placed.
|
||||
*
|
||||
* Harmless on a pack with no fittings — the list is empty, `tick` does
|
||||
* nothing, and `houseLevel` still reports the hour so the caller's rig can
|
||||
* make its own decision.
|
||||
*/
|
||||
const luminaires: Luminaires = createLuminaires(furnishings.luminaires);
|
||||
|
||||
const robots: RobotLayer | null =
|
||||
options.robots && options.robots.length > 0
|
||||
? createRobotLayer(plan, { materials, robots: options.robots })
|
||||
: null;
|
||||
if (robots) {
|
||||
scene.add(robots.group);
|
||||
/**
|
||||
* Once, not per frame.
|
||||
*
|
||||
* `robots()` hands back a stable array of stable `Vector3`s that the layer
|
||||
* mutates in place, so the luminaires are reading this frame's positions
|
||||
* through a reference taken at setup. Calling it every frame would allocate
|
||||
* nothing extra but would imply the array were a snapshot, which it is not.
|
||||
*/
|
||||
luminaires.setWalkers(robots.robots());
|
||||
}
|
||||
// A public office has no presence layer, rather than an empty one. The
|
||||
// difference is not cosmetic: an empty `PresenceLayer` is a `THREE.Group`
|
||||
// named "presence" hanging in the scene graph, a `setPresence` that works, and
|
||||
@@ -676,6 +741,13 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
|
||||
kit.applyLighting(state);
|
||||
paintHorizon(state);
|
||||
},
|
||||
setSolarElevation(degrees) {
|
||||
luminaires.setSolarElevation(degrees);
|
||||
},
|
||||
houseLevel: () => luminaires.houseLevel(),
|
||||
setWalkers(walkers) {
|
||||
luminaires.setWalkers(walkers);
|
||||
},
|
||||
// Stepping back out to the city should retire the hover with it, or the
|
||||
// detail card for whoever the pointer was over survives the journey.
|
||||
onExit: () => kit.resetPick(),
|
||||
@@ -683,8 +755,14 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
|
||||
if (disposed) return;
|
||||
kit.tick(dt);
|
||||
updateOcclusion();
|
||||
// Robots first: the lights above them should respond to where they are
|
||||
// *now*, not to where they were last frame.
|
||||
robots?.tick(dt);
|
||||
luminaires.tick(dt);
|
||||
},
|
||||
dispose() {
|
||||
robots?.dispose();
|
||||
luminaires.dispose();
|
||||
if (horizonPlane) {
|
||||
horizonPlane.geometry.dispose();
|
||||
(horizonPlane.material as THREE.Material).dispose();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user