# 3D: lights, models, materials

Sindri AI knowledge pack, file `09-3d.md`. 3D lighting and shadows, imported GLB models, materials, and day/night.

Start with `00-start-here.md` if you have not read it. Every fact here is copied from the Sindri repository; nothing in this file is a guess.

## Contents of this file

- **3D point and spot lights** (from `docs/3d-lights.md`): Point-light verification (before spotlight extension); Spotlight verification; Typed runtime light access
- **Imported models** (from `docs/imported-models.md`): Asset boundary; CPU collision preparation; Renderer boundary; Scene and facade boundary; Supported subset and diagnostics; Verification; Project loading and export; External crawler proof; Editor boundary; Explicit runtime collision binding; Explicit convex model binding; Shared shadow reception and fog proof; Imported shadow casters; Authored numeric material overrides; Authored GLB self-emission
- **Standard material factors** (from `docs/rendering-materials.md`): Scene authoring; Reusable material profiles; Evidence; Imported emission; Textured geometry normals; Face visibility
- **Scripted day/night and mounted lights** (from `docs/daynight.md`)

---

Source: `docs/3d-lights.md`

## 3D point and spot lights

This is feature proof for Explorer's planned lighting track. No in-tree game
uses 3D local lights yet. GLB light import and local-light shadows remain open; 2D lights have their own independent contract.

`sindri.light.kind` accepts `directional` (the default), `point` or `spot`.
Existing scenes keep their directional light. `enabled` defaults to true and
controls illumination independently of entity activity. All kinds carry color and
intensity; `range` defaults to eight world units and must be finite and
positive. A local light requires finite nonnegative linear RGB and intensity.
The first active directional light remains the sun. Invalid lights fail strict
frame extraction; the editor's tolerant extractor reports them and omits their
contribution.

Local-light positions come from the composed entity transform every rendered
frame. Moving, turning or scaling a parent moves its light; a point's rotation
has no effect, while a spot shines along the composed forward axis (`-Z`). Range
and cone angles ignore entity scale. An inactive entity or ancestor removes its
contribution. Disabled and zero-intensity local lights consume no slot. Scene extraction
validates all lights, then selects the eight strongest active local-light
intensities; stable world iteration breaks ties. Selection is shared by every
geometry kind, independent of camera and distance. There is no spatial light
assignment yet.

The renderer accepts eight optional `LocalLight` values on
`WorldLighting.local_lights`. `validate` returns a typed field error; invalid
low-level values pack as disabled lights. Cubes, inline/cached meshes, voxel
meshes and imported primitives use the same world-space local-light function.
Standard materials use the shared GGX response; legacy world materials use
Lambert diffuse. Ambient fill is added once. For distance `d` below range `r`,
radiance scales by `(1 - (d / r)^2)^2 / max(d*d, 1)`: finite near the source,
inverse-square beyond one world unit, smoothly zero at the range. A surface
exactly at the source has zero directional contribution. Local light is added
before fog and post.

Spots add `inner_angle` and `outer_angle`, half-angles in radians, defaulting
respectively to 20° and 30°. Validation requires `0 <= inner < outer <= pi/2`
and distinct f32 cosine values, including on non-spot kinds so switching kinds
retains valid settings. Inside the inner cone the light has full strength;
between the two boundaries it fades with smoothstep in cosine space; outside it
contributes zero. The same eight slots select points and spots together, without
an extra pass. Raw invalid renderer inputs pack as disabled lights.

Local light is independent of directional-shadow visibility. Points and spots do
not cast shadows or respect occluders; that limitation is visible around walls.
Emission still lights only its own material. World-mesh AO applies to point
shading as it does to sun shading; imported geometry still has no authored
mesh-AO channel.

Each color draw carries eight 64-byte records, including empty slots. Textured
color uniforms are 880 bytes and model color uniforms 944 bytes (including the optional silhouette color); pass/write
counts, shadow uniforms and retained geometry do not change. Batching, shared
light buffers and whole-frame performance targets remain open. The fixed 3D
workload now uploads 293,744/271,280 uniform bytes with shadows on/off,
retaining 602/301 draws and 603/301 passes.

The editor offers Create Point Light and Create Spot Light, with one checked
spawn/undo transaction, and the existing Light kind/color/intensity/range
inspector. Cone angles use degree controls in the editor; scenes store radians.
Invalid inspector light edits are rejected before commands are emitted, retaining
the last valid payload and undo history. Externally authored invalid lights still
use the strict/tolerant extraction policy above. A selected point shows three clipped world-space range circles and a
pickable center icon. Selected spots show inner/outer cone rings at the radial
range cutoff plus four rays each; spots also show their forward arrow. Create
Spot Light starts at `[0, 2, 0]`, aimed down, with intensity/range eight. Guides
use composed rotation and world units, clip at the near plane and do not author
geometry. Scene studio lighting replaces sun, points and spots; Game and
authored Scene lighting use the extracted set.

Scene tests cover composed moving/turning/scaled parents, disable/re-enable,
strongest-eight selection and invalid overflow lights in strict/tolerant modes.
GPU controls compare standard point shading across primitive, inline/cached and
imported geometry, legacy diffuse, range, zero intensity, facing, invalid input,
multiple lights, consecutive changes and retained cache uploads. Native/browser
controls generated by `prepare-point-lights.py` reuse the checked 3D Benchmark
assets and ready script; only the scaled/turned light carrier's active state
differs. CI retains both captures and verifies red and blue surface lighting.

### Point-light verification (before spotlight extension)

Final local preflight passes 1,474 native tests across render/GPU/scene/editor;
all 11 catalog tests pass (1,485 unique). All 56 GPU-package tests repeat with
GPU/timestamps required. Warning-denied workspace Clippy/WASM, format/file-size
checks and native/editor authored-color captures pass. No dependencies or Decay
scripts change; generated light metadata includes point kind and range.

The native 960×540 pair brightens 97,490 pixels (9,083 red, 66,347 blue). Fresh
Chromium brightens 97,397 (9,016 red, 66,149 blue), fetches all six original
assets and reaches ready in both controls without runtime/GPU errors. The
ambient-only off control deliberately has four colors; its browser smoke uses a
scoped threshold, while the default remains eight. Existing 300-prop browser
shadow controls retain 2,110 changed and 2,088 darkened pixels.
Standalone/editor three-frame shadow controls verify the exact updated counters
and nonzero GPU timestamps. These are correctness checks, not a performance
claim.

Native editor review selects the parented warm point and inspects its clipped
range guides, edits range 6 to 3, saves, undoes/redoes and restores 6. Create
Point Light persists position `[0, 2, 0]`, intensity 8 and range 8, and one
Undo/Redo removes/restores the whole entity. Reopen/Play reaches the reused
ready goal with colored lighting on imported and world geometry; Stop/save
restores the authored 13-entity scene. The review uses X11/software Vulkan;
broader GPU and native-platform review remains open.

### Spotlight verification

Spot cone GPU regressions aim the same light left/right across each world mesh
path and imported geometry, compare RGB, exercise feathering, legacy diffuse,
invalid raw inputs and retained geometry across updates. Scene regression mixes
points/spots past the shared capacity, rotates a scaled parent, checks world
position/direction and refuses invalid angle pairs in strict/tolerant
extraction. The editor guide regression checks selection, composed direction,
scale independence and behind-camera hiding. `prepare-spot-lights.py` derives
the same checked Benchmark assets/ready script; controls differ only in local
pitch, aiming colored cones down toward surfaces or up into empty sky. CI
retains native/browser pairs and reuses the local-light RGB comparison.


Final local validation covers 1,490 unique native/catalog tests: the four-crate
preflight's 1,478 tests, the additional inspector-command regression, and all
11 catalog tests. All 57 GPU-package tests run again with Vulkan/timestamps
required. After the authoring fix, repeat all editor tests, warning-denied
native checks, workspace Clippy/WASM and the native editor build. Regeneration,
format/file-size gates and native authored-color verification pass. There are
no dependency or Decay-script changes.

Native spotlight controls brighten 34,135 pixels (3,965 red and 26,020 blue);
rebuilt Chromium brightens 34,093 (3,955 red and 25,974 blue). All six browser
cases fetch six assets and reach ready without runtime/GPU errors: spot
on/away, point on/off and original 300-prop shadow on/off. Point controls retain
97,490/97,397 brightened pixels and browser shadows retain 2,110 changed / 2,088
darkened pixels. Inspect both spotlight captures; three consecutive standalone
shadow frames verify the updated counters and nonzero GPU intervals. The final
editor on/off three-frame reports match the same physical counts and bytes,
with required nonzero timestamps. The native editor authored-color capture
passes at 1400×1000.

Native X11/software-Vulkan editor review selects the parented warm spot, pulls
back to inspect both clipped cone rings and degree controls, rejects a 10° outer
angle below the inner cone without altering the saved 0.75-radian value, and
saves a valid 50° edit. Undo/Redo restores/reapplies it, then restores the
original cone. Create Spot Light saves its down-facing transform, intensity and
range eight, and 20°/30° cones; one Undo/Redo removes/restores the whole entity.
Reopen/Play reaches the reused ready goal with the authored spot pools, and
Stop/save restores the 13-entity scene with two spots. This remains feature
proof; scripted mounted-light and in-tree game proof are separate requirements.

### Typed runtime light access

`this.light` and an entity's `.light` expose `Color`, numeric intensity/range and
inner/outer half-angles, and boolean `enabled`, through one typed host surface.
Reads honor omitted defaults. Every write validates a full candidate before
committing; an error keeps the old payload. Whole Color writes are atomic and
channel writes retain other channels. RGB is finite and nonnegative; Color
alpha is one and writes must keep it one. Non-finite/overflowing values, invalid
cone intervals, absent lights and stale references report runtime errors.

`enabled` changes illumination independently of entity activity, so a light's
own script continues ticking while off. Inactive ancestors still suppress it.
Directional selection skips disabled suns; points/spots consume no slots when
disabled. Position and direction continue to derive from live composed transforms.
No renderer uniforms or passes change in this slice.

`examples/lighting3d` is a feature example for Explorer's planned lighting track,
not in-tree game adoption. Lights start authored black, intensity zero and off.
Decay sets their values, alternates a blue point and a warm spot, translates and
turns/tilts their scaled parent, and rotates the sun. After three seconds the
ready goal restores the carrier and leaves all lights on, or off if the Camera's
exported `lit` is false. The goal test opens the real project, compiles the script
and steps Session to both goals, checking intermediate world light data, entity
activity, the sun, and cube/inline/GLB extraction. Native/browser phase captures
and final on/off pairs exercise the script-driven rendering; editor Play uses the
same Session. This is separate from day/night environment control and game proof.


The local validation includes 1,936 tests across the affected crates and editor,
warning-denied workspace Clippy, a workspace WASM check and typed script preflight.
Native phase captures add 7,568 red pixels and remove 61,462 blue pixels;
browser phases add 5,065 red pixels and remove 71,641 blue pixels. Final lit/off
pairs brighten 91,624 native and 91,020 browser pixels. The browser fetches all six
assets, reaches the goal and reports no runtime or GPU errors. Native editor
Play reaches both exported goals, and Stop/save restores all 13 authored entities
and their black, zero-intensity, disabled lights while retaining the edited `lit`
property. The original 300-prop browser workload and its shadow comparison also
pass (2,088 darkened pixels). Fog/post environment controls and in-tree game
adoption remain open. Day/Night Lab now exercises dynamic sun-shadow direction
and a project clock; see `daynight.md`.

Lighting Lab now also exercises runtime ambient/background transitions; see
`runtime-environment.md` (in `05-projects-scenes-prefabs.md`) for that separate slice.

---

Source: `docs/imported-models.md`

## Imported models

Status: bounded static GLB decoding, reusable GPU rendering, scene references,
external-project export and native/browser project loading are implemented.
The actual Low Tide cutaway crawler has been visually inspected in native
offscreen Sindri and exported WebGPU Sindri. This is static model support, not
the whole glTF specification or completed imported-model editor UX.

### Asset boundary

`sindri-assets::ModelAssetDecoder` consumes bytes from the normal asynchronous
asset source/queue/store pipeline. It accepts self-contained binary GLB 2.0,
using `gltf` with `utils` and `names`, without the library's filesystem importer.
Neither GPU ownership nor synchronous browser I/O belongs to this decoder.
`AssetLoader<ModelAssetDecoder>` coalesces repeated requests by logical asset ID.

`ModelAsset` retains nodes in their original index order, names, children,
local column-major matrices, scene roots, and mesh references. Geometry is
stored once per mesh primitive even when multiple nodes reference it. Matrices
are glTF Y-up already; do not convert source construction coordinates again.
The renderer bridge must multiply the entity world matrix by each accumulated
node matrix, retaining local node data for later independent manipulation.

Primitives retain positions, normals, UV0, material indices, and 16-bit or 32-bit
indices. Non-indexed triangles acquire sequential 32-bit indices. Missing
normals are generated from triangle geometry; missing UVs are zero only when
the primitive does not need a texture. Base-color factors are linear RGBA.
Materials retain metallic/roughness factors, linear RGB emission, double-sidedness
and alpha cutoff.
Embedded PNG/JPEG base-color images use the existing texture decoder; sampler
wrap and filter enums are retained for GPU upload.

### CPU collision preparation

`ModelAsset::collision_mesh(ModelCollisionLimits)` produces owned positions and
triangle indices in model space, with one source range per instantiated node,
mesh and primitive. It walks selected roots depth-first in authored order and
accumulates column-major local matrices. Repeated meshes expand at each selected
node; unreachable nodes stay out. Entity world transforms remain the host's
responsibility. No coordinate conversion, material filtering, welding, hull
construction, physics body or runtime collider is implied.

This foundation is added for the planned Explorer showcase. Assets own CPU
geometry preparation; physics will own backend geometry and queries; scene and
runtime will bind logical model references through shared geometry resources.
The asset crate acquires no physics, renderer, scene or new math dependency.

Reflection reverses each triangle's second and third index, preserving authored
orientation under negative node determinants. Both decoded index widths and
generated non-indexed triangles produce checked u32 indices. Per-part vertex and
triangle ranges retain original node, mesh and primitive indices. Normals, UVs
and materials do not enter collision preparation; masked holes therefore have
no implied collision policy.

Default limits allow 65,536 selected hierarchy nodes, 1,000,000 expanded
vertices and 1,000,000 triangles, counting repeated instances. Traversal is
iterative, including deep chains. Limits are checked before expanding pending
children, appending geometry or copying indices. Vertex indices also stay within
u32 range. A caller can supply other limits; zero rejects nonempty data.

Preparation validates the selected traversal and geometry even when public
asset fields were edited after decoding. Missing/repeated nodes, missing meshes,
empty/incomplete/out-of-range triangles, nonfinite vertices, singular/projective
transforms, overflowing accumulated matrices/positions and degenerate transformed
triangles return `ModelCollisionError`. Unselected data is not revalidated here;
the decoder remains responsible for the complete GLB. Failure returns no partial
mesh and never changes the original model. Affine matrix products and triangle
area checks use f64 intermediates with checked narrowing to model-space f32.

Six native regressions use the existing static GLB fixture and cover hierarchy,
reused meshes, source ranges, both index widths, non-indexed geometry, reflection,
exact bounds, 8,192-node traversal and malformed mutable data. The inspector
prints 15 vertices, five triangles and five primitive instances for that fixture:

```bash
cargo run -p sindri-assets --example model -- \
  crates/sindri-assets/tests/fixtures/models/static-model.glb --collision
```

This is CPU preparation proof. Physics now provides immutable static triangle
resources and standalone keyed groups with native query/landing/replay tests;
see the physics contract (in `07-physics-and-movement.md`). Standalone convex
hull resources and mixed rigid bodies now have native landing/control/replay
tests as well. Static model scene bindings and native/browser/editor-session
feature proof are described below. Explicit convex model scene bindings now have native regression proof;
automatic convex loading/authoring and Explorer game proof remain pending.

### Renderer boundary

`sindri-render::RenderModel::new(ModelData)` validates immutable geometry,
materials, images and hierarchy without depending on assets or scenes. The
public facade adapts decoder data once; hosts share `Arc<RenderModel>`
instances. The original
node names, indices, local matrices and mesh references remain available; a
separate list accumulates selected-scene node matrices for drawing.

`FrameCommand::Model` carries that shared resource and the entity world matrix.
The GPU cache uploads each mesh primitive and base-color image once, reuses
them across nodes, instances and frames, and releases entries when the resource
loses its last owner. Cache statistics expose residency, uploads and draws.
Each draw has an independent reusable uniform slot.

The model vertex path retains normals and UVs instead of converting through
inline `SurfaceMesh` or the existing position/UV/AO vertex format. Both index
widths are used directly by the GPU. Drawing composes entity and hierarchy
matrices, transforms normals by inverse transpose, writes/tests depth, and
handles mirrored instances and double-sided materials. Embedded base-color
textures are sampled as sRGB, factors are linear, and alpha masks discard.
Ambient plus the existing directional world light uses metallic/roughness
factors in a direct GGX lighting approximation. Environment-map lighting,
texture mipmaps remain deferred. Models now cast into and receive the
same directional shadow map as textured world geometry and use its shared WGSL
fog calculation. Shadowing attenuates direct diffuse/specular light, preserving
ambient fill. Fog supports distance, exponential density and low-altitude terms.
Model bind groups are invalidated when the shadow-map resolution changes, while
geometry and embedded textures stay resident. Each color uniform is 400 bytes.
The existing cube/mesh lighting formulas and vertex layouts are unchanged;
shared materials and one lighting formula are still pending.

`set_fog` applies to models as well as world meshes. Editor viewports already
forward authored fog; shipped player/capture hosts do not yet forward it. Model
fog GPU evidence therefore does not claim authored browser fog delivery.

Invalid render resources and singular/nonfinite model matrices return typed
errors. Geometry buffers and image dimensions are checked against device
limits before upload. The pipeline uses target-independent wgpu APIs.

### Scene and facade boundary

A scene entity references an external asset without embedding its vertices:

```json
{ "components": { "sindri.model": { "asset": "models/crawler.glb", "layer": 0 } } }
```

`ModelComponent` validates the logical `AssetId`. `referenced_models(world)`
deduplicates all references, including inactive entities needed by future scene
switches. Hosts decode once, call `sindri::model::prepare` once per asset and
bind the shared resource with `SceneExtractor::bind_model`. Missing bindings
name the asset in a typed extraction error. Active instances extract into the
opaque 3D stage with the authored layer, world camera and entity world matrix.
The facade performs the conversion so neither scene nor render depends on the
asset crate. Local node matrices remain Y-up and are never converted twice.
The component has no invented default model. The editor completes Add Component
with the first supported GLB in the project, and its asset field uses the model
picker described below.

### Supported subset and diagnostics

Only triangle primitives and one embedded binary buffer are supported. External
buffer/image URIs, sparse accessors, nontriangle geometry, blending, skinning,
morph targets, additional vertex attributes and non-base-color textures fail
with an asset-aware `AssetDecodeError`. Required extensions fail explicitly.
Optional extensions, animations and cameras produce retained warnings and do
not drive Sindri lighting, motion or cameras. Hosts must report these warnings.

Buffer views and accessor ranges are checked before the library's geometry
readers run. Attributes must have matching lengths and finite values, indices
must form complete in-range triangles, and node cycles/multiple parents fail.
This is a deliberately bounded static model import, not the whole glTF spec.

### Verification

`crates/sindri-assets/tests/fixtures/models/static-model.glb` is a deterministic
small fixture with two mesh primitives/materials, both index widths, a reused
mesh, parent/local transforms, non-indexed geometry, and one embedded texture.
Its sibling `generate.py` reproduces it using the Python standard library.
Decoder tests cover these plus malformed ranges, cycles, unsupported features
and repeated asset requests. Renderer tests cover hierarchy and invalid resource
validation. The GPU imported-model test reads back material/texture pixels,
checks distinct instance uniforms, mirrored-instance depth, both index widths,
and one upload across repeated draws, followed by residency release. Scene and
facade tests cover logical references, world transforms, disabled discovery and
conversion without an axis change. Native queue tests bind repeated scene
references to one resource. Export tests cover project-root/asset-directory
layouts, repeated references, other scenes, placed prefabs, original bytes/hash,
and missing/malformed model diagnostics. No unit
test depends on the external crawler.

Inspect an actual file without claiming a render proof:

```bash
cargo run -p sindri-assets --example model -- path/to/model.glb
```

### Project loading and export

`AssetKind::Model` identifies binary GLB assets in the manifest. Export walks
all listed scenes and expanded prefab worlds, including inactive references,
validates models through the same decoder and packages original bytes under
the existing content hash. Repeated references produce one manifest entry.
Projects with assets beside `sindri.toml` and those using `assets/` both work.
Missing files and unsupported/malformed GLBs stop export with named diagnostics.

Both of `sindri-player`'s sources load models. The browser source sizes a
model queue from the manifest, verifies fetched bytes and decodes them; the
native source (`sindri-player <project>`) decodes every `.glb` under the
project's assets. `Player::install` prepares and binds each logical model once,
and reports each decode warning against its asset ID. `project-capture` and
`project-benchmark` bind every `.glb` the project holds through the filesystem
source and the same asynchronous queue and decoder, so a scene reached later,
or a prefab spawned later, draws without a load. None reparses model instances
during extraction. Every host applies authored ambient and directional lighting
before drawing. A project's assets are `assets/` when it has one and the
project root otherwise (`sindri_runtime::project::assets_root`), as export
reads them.

### External crawler proof

[Low Tide PR #1](https://github.com/vardirhq/low-tide-3d/pull/1) references the
unchanged `Low_Tide_Kit/starter_crawler_cutaway.glb`. Native and Chrome headless
WebGPU captures at 1200 × 1000 were visually inspected on 2026-10-09: the tracks,
ramp, wood deck, furniture and cabin are visible with correct Y-up orientation,
material colors and orthographic three-quarter framing. The model has 177
nodes, 19 meshes, 85 primitives, 16 materials and no base-color textures.
No construction-coordinate conversion, inline vertices or replacement art is
used. A simple seabed, ambient light and directional sun are authored in the
external scene; no Low Tide behavior is added to engine crates.

The export carried the scene, a solid ground texture and the original
888,192-byte GLB, SHA-256
`de9a1ec5d3d99cd8e2ca47ebe7e48d9fb202ccd4fddd09be85b6ea4818730ec2`.
Browser smoke confirmed WebGPU, loading completion and HTTP delivery of all
three kinds. See the external project's
[proof and reproduction commands](https://github.com/vardirhq/low-tide-3d/blob/poc/isometric-crawler/docs/POC.md).
That historical proof has shadows disabled. Model shadow reception is now
implemented; imported-model shadow casting is now implemented.

### Editor boundary

The editor loads the models a scene and its prefabs name through the same
queue and decoder as its textures (`editor/src/textures/models.rs`), prepares
each once and binds it to both the Scene and Game views' extractors. A changed
file is loaded again and rebound. Decode warnings and failures are console
lines. Until a model arrives, or when its file will not decode, the editor's
tolerant extraction draws the rest of the scene and names the waiting entity
as a problem; a game still fails on an unbound model, because every host binds
before it draws. Both render-model and static-model-collider `asset` fields now
declare `FieldMeaning::Asset(AssetKind::Model)`. The inspector offers project GLBs
(case-insensitive extension matching), keeping glTF/OBJ/FBX files visible in the
project browser without offering them as supported model choices. References
remain typeable and schema checked. Add Model and Static Model Collider complete
their field templates with a real project GLB; without one they are withheld.
Rendered models belong to world space, and static collision cannot share its
owner with other physics bodies, colliders or characters. Render and collision
fields have separate widget scopes and separate component undo entries; a drag
within a component still merges. Native directory suffix discovery also ignores
ASCII case without changing asset IDs, so a chosen `.GLB` runs natively.

Native authoring review (`docs/model-collision-editor-review.md` in the Sindri repository)
verifies independent selections, Undo/Redo, Add Model, save/reopen and Play using
a different GLB wall, then opens that authored project in the real native player
window and visually verifies wall obstruction and the walker on the goal pad. This is general authoring for the imported-content track,
requested by external Low Tide; Explorer's in-tree game adoption remains open.
Thumbnails, model drops, triangle picking, node inspection and material overrides
remain in the 3D update (`docs/3d-update.md` in the Sindri repository).
External Low Tide is the request that found this gap; it does not by itself
satisfy this repository's in-tree game proof rule.

### Explicit runtime collision binding

A decoded `ModelAsset` can now be delivered to
`Session::bind_model_collision(id, &asset)` for an authored
`sindri.physics3d.model_collider` reference. CPU preparation expands the selected
GLB hierarchy once; the scene driver owns static geometry and applies composed
positive scale, pose changes and lifecycle on its shared fixed step. Rendering
and collision references are independent. Native Session tests use the binary
GLB fixture for character obstruction, checkpoint replay and asset replacement.
See scene ownership and limitations (in `07-physics-and-movement.md`).

This is an explicit host API. Shared asset discovery and export include
collision-only references, including inactive entities and prefab contents, and deduplicate render/collision uses.
Export verifies and ships the original GLB bytes. The shared native/browser
player now prepares collision only for authored model-collider references after
loading completes, before starting Session. Discovery expands every project scene
and prefab, including instance overrides and inactive entities, so later scenes
and spawned content already have bindings. Missing assets or failed collision
preparation report the logical asset ID. Render-only models need not contain
valid physics geometry. Native headless `ProjectRun` loads only authored collision
references through the filesystem worker queue before stepping, including later
scenes and spawnable prefabs, in both project asset layouts. Render-only model
bytes remain a drawing host's responsibility.

Editor Play binds decoded collision models from its asynchronous asset queue
before creating the recording or starting scripts. Startup discovers the live
world's unsaved edits, inactive entities, declared later scenes and expanded
spawnable prefabs. Later-scene collision assets remain retained for the run.
If an asset is still loading, Play reports its ID and stays stopped; retry when
loading completes. Missing, malformed and invalid collision geometry also keep
Play stopped. A successful GLB reload replaces render and collision resources
before the next step; failed collision preparation retains the previous binding.
Stop releases Play-only retention and restores the authored world. New collision
asset references introduced during a run require stopping and starting Play again.

Model Collision Lab (`examples/model-collision/README.md` in the Sindri repository) is the player feature
proof: one GLB supplies the rendered ground/wall and static collision, while
Decay drives the walker into the wall and around it to a goal. The native player
regression opens/compiles the project and steps the keyboard route through
Session. Editor Play and `ProjectRun` now run the same obstruction/bypass/goal
route with matching world state at every step; checkpoint restoration and replay
retain wall collision. Headless regressions cover collision-only layouts,
inactive missing/malformed assets, later scenes and nested prefab overrides.
The exported browser smoke plays the same route with ordinary keyboard
input, verifies the GLB fetch and checks runtime/GPU errors. A native editor
review (`docs/model-collision-editor-review.md` in the Sindri repository) verifies the goal, moved-wall reload,
invalid-reload retention, Save refusal and Stop restoration. It remains feature
proof; Explorer game adoption is open.
Convex model scene authoring and Explorer proof also
remain scheduled in slice 6; rendered GLBs do not automatically acquire collision.


### Explicit convex model binding

`Session::bind_model_convex_collision` prepares at most 4,096 expanded vertices
from a decoded selected GLB hierarchy, then builds shared scene-owned volumetric
geometry. The `sindri.physics3d.model_convex_collider` component can share a rigid
body and primitive pieces; a single hull fills all model gaps and concavities.
Planar/oversized/invalid geometry fails atomically. Positive composed nonuniform
scale, dynamic writeback, lifecycle and checkpoint-aware replacements have native
scene and Session regression evidence. See ownership and limits (in `07-physics-and-movement.md`).
Convex-only references now participate in discovery/export and asynchronous
player loading, native headless runs and editor Play. Discovery retains all
representations needed per logical asset, and atomic preparation prevents a
failed hull reload from partially replacing shared triangle collision. Editor
Add Component uses a project model and enforces compatible ownership. Model
Collision Lab exercises a falling imported prop and a Decay impulse/travel goal;
Explorer adoption remains open.


Convex-model prefabs also accept Decay motion commands immediately after spawning,
before their first physics body is materialized. Project discovery preloads
spawnable prefab GLBs, while scene synchronization supplies actual scaled hull
mass for queued impulses. Model Collision Lab uses a typed prefab export to
spawn its prop, verifies copied pending reads and reaches its existing prop goal.
See the spawn-window contract (in `07-physics-and-movement.md`).

### Shared shadow reception and fog proof

`crates/sindri-gpu/tests/imported_models/effects.rs` renders a textured caster and
an imported receiver through the real prepared-frame encoder, reads back pixels,
and checks map resizing (256 → 1024 → 256) while moving the caster,
shadow disable/restore and one retained
model upload. A second regression checks full distance, exponential and height
fog on both model and cube pixels, then exact restoration after disabling fog.
Existing material, hierarchy, normals, mirrored-instance depth and cache tests
remain in place.

`scripts/browser/prepare-model-receiver.py` derives a small control from the
checked 3D Benchmark project and its unchanged startup/ready script. Model
Collision Lab's original GLB provides green ground and an orange wall; the
textured cube and, since model casting landed, the imported wall cast shadows. Native and real Chromium controls compare shadows
on/off and require more than 100 darkened green pixels, proving reception on
imported geometry specifically. CI retains both captures. This is feature proof
for the planned Explorer showcase; in-tree game adoption remains open. Unified materials, world-mesh cutout casting and game adoption remain separate work.

### Imported shadow casters

The model shadow renderer walks the same retained node hierarchy and cached
primitive buffers as color rendering, composing entity × accumulated node
matrices. It handles both index widths, mirrored orientation and authored
single/double-sidedness without duplicating geometry. Its depth-only fragment
shader samples the embedded base-color texture with the authored sampler,
multiplies material alpha and discards below the alpha-mask cutoff. Opaque
materials cast solid silhouettes regardless of texture/factor alpha. World
cube/inline/voxel cutout shadows remain a separate gap.

Shadow draws use independent reusable 96-byte uniform slots and bind only their
material texture/sampler, never the depth map they write. The color draw keeps
its separate 400-byte slot. The shadow stage reports typed model failures through
`FrameEncodeError`; malformed transforms do not become silent missing casters.
One asset upload serves shadow and color. `ModelCacheStats.draws` retains its
color-primitive meaning; `MeshDrawStats` includes both stages.

Three GPU regressions cover retained primitive instances and both index widths,
consecutive submissions, material/texture alpha holes and fully masked surfaces,
opaque-mode alpha, mirrored/single/double-sided facing and typed frame errors.
The existing moving-caster/map-resize and fog tests still pass. The generated
`--model-caster` control contains only imported geometry (green ground/orange
wall and a purple GLB box). Native and real WebGPU shadow on/off comparisons require
darkened green receiver pixels and the reused startup/ready goal; CI retains
both pairs. This is feature proof for planned Explorer adoption, not completed
in-tree game integration. Models now cast and receive; shared materials, improved
filtering/cascades and shipped-host authored fog delivery remain open.

### Authored numeric material overrides

A model owner may carry `sindri.material`. Shared numeric factors replace its
primitive base-color/metallic/roughness factors and add emission through the same
GGX shader as opt-in world meshes; original embedded textures, masks and sidedness
remain owned by each primitive. Alpha overrides affect color and shadow masks.
Removing the component restores imported factors, without geometry uploads.
This is an entity-wide override, not node/primitive inspection or a shared
material asset. Core GLB emissive factors are retained and rendered; emissive
maps and full material unification remain open.
See the contract and proof (in `09-3d.md`). Model color uniforms are
now 416 bytes; 96-byte shadow uniforms remain unchanged.

### Authored GLB self-emission

The importer preserves core glTF `emissiveFactor` as linear RGB (default zero,
finite 0–1), and preparation carries it into each shared primitive surface.
Rendering adds it independently of world lighting before fog. Entity material
overrides replace it; removal restores it without geometry uploads. Emission
does not affect alpha masks or shadow coverage. Emissive textures remain
unsupported, and extension policy is unchanged (required extensions rejected,
optional extensions warned/ignored). Decoder, facade, GPU readback and native/
browser GLB controls exercise the path. See material evidence (in `09-3d.md`).
This feature proof precedes Explorer game adoption.

Optional occlusion silhouettes (`docs/model-occlusion.md` in the Sindri repository) reveal model subjects behind opaque terrain.

---

Source: `docs/rendering-materials.md`

## Standard material factors

This is the numeric foundation of the planned Explorer material track, with
native/browser feature-control and GPU evidence. Game adoption and the complete
shared material asset/texture/alpha contract remain open. Face visibility has
an independent shared override, described below.

`sindri-render::MaterialFactors` owns linear `base_color` RGBA, `metallic`,
`roughness`, and linear RGB `emissive`. Base color, metallic and roughness must
be finite in 0–1; emission must be finite and nonnegative and may exceed one.
A typed `MaterialError` names the invalid factor. Frame preparation checks
per-pass factors before GPU work; the renderer setter also checks before
replacing its current selection. Roughness is bounded below by 0.045 in the
GGX shader to avoid a singular highlight.

`material.wgsl` supplies one direct GGX response with the existing ambient
approximation for cubes, inline meshes, cached geometry (including voxels) and
imported model primitives. Directional shadows attenuate direct light; emission
lights the surface itself, then shared fog is applied. Emission is independent
of ambient and directional intensity. It does not illuminate neighbors; local
lights remain separate work. HDR emission reaches the target, so an 8-bit target
clips above one; authored bloom/post delivery in shipped players remains open.
There is no environment-map lighting or normal/metallic/roughness/emissive map.

### Scene authoring

Attach `sindri.material` to the entity owning a mesh, model or voxel world:

```json
"sindri.material": {
  "base_color": [0.85, 0.5, 0.08, 1.0],
  "metallic": 1.0,
  "roughness": 0.2,
  "emissive": [0.0, 0.0, 0.0]
}
```

Missing fields use white base color, nonmetallic, roughness one and zero
emission. These are absolute factors, not multipliers on imported primitive
factors. A model override replaces factors across all its primitives while
keeping their embedded textures, alpha masks, hierarchy and shared
geometry. Remove the component to restore imported factors. Alpha factors
participate in imported masks in both color and shadow draws. Opaque imported
materials continue ignoring alpha for coverage. Missing `sidedness` preserves
imported face visibility. This component does not add blended transparency or
unify geometry-owned alpha policy.

Without the component, existing textured world geometry retains its Lambert
appearance; imported models retain their authored factors and existing GGX
response. Opting in selects the common standard path. Migration of legacy
looks, shared material texture bindings and per-node model overrides remain pending.
Factors apply to the owning entity, not its children or 2D sprites/UI.

The editor's Rendering family offers Material with checked generic controls:
base-color picker, metallic/roughness numbers and numeric RGB emission (the
vector controls use X/Y/Z labels). Fields serialize through the normal checked
commands and undo history. Emission uses numbers so HDR values remain authorable.
Voxel material edits change uniforms without re-meshing resident sections.

### Reusable material profiles

A `.profile` asset with `type: "render_material"` supplies the same numeric
factors and sidedness to cubes, inline/cached surfaces, voxel owners and imported
model primitives. This is a bounded asset-authoring slice for the planned
Explorer track, proven in Lighting Lab. Texture-bearing materials, unified alpha
and game adoption remain open.

```json
{
  "format_version": 1,
  "name": "Polished metal",
  "type": "render_material",
  "values": {
    "base_color": [0.7, 0.7, 0.7, 1.0],
    "metallic": 1.0,
    "roughness": 0.15,
    "emissive": [0.0, 0.0, 0.0],
    "sidedness": "inherit"
  }
}
```

Select it with `"sindri.material": {"profile": "materials/polished.profile"}`.
The profile is resolved first. `override_factors: true` replaces **all four**
factor fields with the component's local values, including their ordinary
white/nonmetallic/rough defaults when omitted. `override_sidedness: true` independently selects the local `sidedness`, including
an explicit `inherit` that restores imported primitive settings. With no profile,
inline component semantics remain unchanged, regardless of the override flags.
Removing the component restores legacy/imported factors and visibility.

Profile references are validated logical asset IDs with a `.profile` suffix.
Other profile types remain game-owned. The engine-owned render type rejects
unknown fields, recursive profile references, texture/alpha fields, invalid
versions, invalid factors and unknown sidedness. Missing/wrong-type references
fail strict extraction; tolerant editor views report the entity and skip its
draw rather than silently drawing with defaults.

The project browser offers **New render material**, with a full-value starter.
The existing structured profile inspector edits it; Save validates render fields
before replacing disk content. A rejected Save keeps the form editable; a valid
correction clears the error. Material component fields use the existing profile asset
picker and checked scene commands with normal Undo/Redo. The picker lists all
profile types; extraction reports mismatched types. Profiles use the existing asynchronous editor
loader/watcher and browser asset loader; a failed editor reload retains the last
valid values. Successful reloads replace the complete source set for both editor
viewports. Native players and headless project runs install the same checked
sources. No filesystem access is added to extraction or rendering.

The exporter discovers references on inactive entities, later scenes and expanded
prefabs, deduplicates the assets and validates their type/content before writing.
A render material profile carries values only: it owns no texture handles, GPU
buffers or shader variants. Each frame snapshots resolved factors/policy; reloads
and local overrides leave retained geometry, imported assets and voxel meshes
resident, and old frame packets retain their earlier values.

Lighting Lab shares three profiles across six objects. Its inline control must
render byte-identically; editing only the polished profile adds green emission
to both a cube and an imported model. Existing scripted light phases, the goal
run and the paired sidedness controls still apply. These are feature controls,
not game adoption or a performance improvement.

Native profile controls at 960×540 render shared and inline values
byte-identically, and preserve the earlier Lighting Lab capture exactly. Editing
only the polished profile adds 17,705 green pixels: 9,483 on the model's side
of the image and 8,222 on the cube's. The sidedness control retains its 3,550
changed/orange pixels. The 300-prop benchmark preserves its settled draw/pass
counters and zero geometry uploads with valid GPU timing, and authored-color
verification passes. These are correctness controls, not a speedup claim.

Real Chromium/WebGPU produces byte-identical shared/inline captures. The shared
edit adds 17,700 green pixels (9,480 on the model side, 8,220 on the cube side).
All five Lighting Lab variants reach the scripted goal and exercise light phases
without runtime/GPU errors; profile exports fetch nine assets and the inline
control fetches six. Existing lighting, sidedness and benchmark controls pass.

Native X11/software-Vulkan editor review creates a render profile, selects an
existing profile, saves and independently undoes/redoes the selection. An invalid
roughness Save is rejected without overwriting disk; correcting it saves normally.
Editing the shared polished asset updates both objects live. Reopening preserves
the profile and scene edits, and Play renders both updated objects.

### Evidence

`standard_materials.rs` reads back equivalent cube, inline, cached and imported
surfaces, compares common factors within two byte values, checks metallic and
roughness changes, self-emission with zero world lighting, and restoration of
legacy/imported factors. Geometry stays cached. Imported-model tests change
material alpha across frames and check color/masked shadows without uploads.
Frame tests reject nonfinite factors; scene tests reject invalid documents,
extract per-entity overrides while retaining shared assets, undo removal and
verify that voxel edits do not rebuild geometry. The generated catalog records
the schema/defaults and base-color meaning.

`prepare-standard-materials.py` derives six authored swatches from the checked
3D Benchmark and reuses its script/ready goal. Native and rebuilt Chromium
controls compare authored factors with absent overrides, including visible blue
self-emission. CI retains both pairs. At 960×540, native controls change 46,877
pixels and add 7,005 emissive-blue pixels; Chromium changes 46,873 and adds 6,990.
Captures were visually inspected. These are correctness checks, not performance
or game-completion evidence.

A native editor review on X11/software Vulkan selects Swatch-0, inspects Material,
edits roughness 1 → 0.25, saves, uses toolbar Undo to restore 1 and Redo to restore
0.25, and saves/reopens with 0.25 retained. Entering 2 is rejected and the saved
value remains 0.25. The Scene view shows the gold swatches, imported highlight
and blue emitting model. Local artifacts are `target/standard-material-inspector.png`,
`target/standard-material-editor-undo-redo.png`,
`target/standard-material-editor-invalid.png` and
`target/standard-material-editor-reopened.png`.

Color uniforms are now 368 bytes for textured geometry and 416 for models;
shadow uniforms remain 64/96. The fixed 300-prop workload remains 602/301 draws
and 603/301 passes with shadows on/off, with 138,032/115,568 uniform bytes.

### Imported emission

Static GLBs retain their authored core `emissiveFactor` through CPU decoding,
facade preparation, shared model resources and color uniforms. The glTF factor
is linear RGB in 0–1, defaults to zero and is validated before upload. A renderer
resource may use finite nonnegative HDR emission, as scene overrides already do.
Required extensions remain rejected; optional extensions, including
`KHR_materials_emissive_strength`, remain ignored with a warning. Emissive
textures still return an explicit unsupported-format error.

A scene override replaces imported emission along with the other numeric factors;
removing it restores the authored value without re-uploading geometry. Emission
is added after lighting/shadow attenuation and before fog. It leaves alpha-mask
coverage and shadow silhouettes unchanged and does not cast light onto neighbors.
This is an import/rendering slice for the planned Explorer; game adoption remains
open.

Decoder tests cover retained/default/invalid values and rejected emissive maps;
facade and resource tests cover transfer and validation. GPU readback checks
zero-light emission, override/removal and one retained geometry upload, plus
unchanged masked shadows on the receiver. `prepare-imported-emission.py` derives
GLB emission on/off controls from the checked 3D Benchmark, keeping scene
materials absent and its script/ready goal unchanged. Native and Chromium controls
exercise actual loading, decoding and rendering; CI retains both image pairs.
Uniform sizes, draw counts and geometry residency are unchanged by this slice.

At 960×540, GLB emission changes 20,962 pixels and adds 18,050 blue pixels
in the native control; rebuilt Chromium changes 20,960 and adds 18,034. Both
browser controls fetch all six assets, reach the script's ready goal and finish
without runtime/GPU errors. Native/browser pairs were visually inspected.
Local gates pass 212 scoped tests (40 GPU tests also run with a required Vulkan
adapter/timestamps), warnings-denied Clippy and WASM checks, formatting, file-size
checks and authored-color capture verification. This is correctness evidence,
not game completion or a benchmark improvement.

### Textured geometry normals

Cubes carry unit outward face normals. Inline `sindri.mesh` surfaces may supply
`surface.normals`: an array of object-space XYZ vectors with one usable finite,
nonzero normal per vertex. Missing/null normals preserve geometric flat shading.
Partial arrays, zero vectors and non-normalizable values are rejected while
validating the component. Existing position/UV/index contracts remain unchanged.

```json
"surface": {
  "vertices": [[-1,-1,0], [1,-1,0], [0,1,0]],
  "uvs": [[0,0], [1,0], [0.5,1]],
  "indices": [0,1,2],
  "normals": [[-0.5,0,1], [0.5,0,1], [0,0.5,1]]
}
```

`TexturedVertex::with_normal` also carries normals in cached render geometry;
zero is its default flat-shading sentinel. Frame preparation rejects nonfinite
normals in inline vertices or cached replacements before encoding. Shading
interpolates transformed normals and normalizes per fragment, in both the legacy
Lambert path and standard GGX. The vertex shader computes the inverse transpose
from cofactors of the model's linear transform; it handles rotation, nonuniform
scale and reflection without changing uniforms. Singular/near-singular transforms
(determinant magnitude below 1e-20) fall back to geometric normals.

World color and directional-shadow pipelines select clockwise front faces
for a negative world determinant, preserving exterior culling for reflected
cubes, inline meshes and cached geometry. Back-face culling is the default;
the shared sidedness override below also applies to these pipelines. Cutout-shadow
policy remains separate work. Legacy low-level
MVP-only draw methods still assume an identity world transform; project frames
use the separate-world-transform methods.

The textured vertex stride grows from 24 to 36 bytes, including existing ambient
occlusion. This adds 12 CPU/GPU bytes per textured vertex, including voxel
geometry; imported model vertices are unchanged. There is one extra resident
color pipeline and one extra shadow pipeline per renderer. Per-draw uniforms,
passes and queue-write counts are unchanged, and cached normal data uploads only
with geometry replacement. Packing/separate streams remain future optimization.

Cube tests verify outward unit normals and triangle winding. Scene tests exercise
validation, extraction and edit/Undo/Redo restoration. GPU readback compares
interpolated inline/cached normals with imported model inverse-transpose shading
under rotation, nonuniform scale and reflection, and checks reflected cube color
and shadows, including open inline/cached casters whose wrong winding would
remove the entire shadow. Native/browser smooth-versus-flat reflected ellipsoid controls reuse the
3D Benchmark's assets and script/ready goal; CI retains captures. This is feature
proof for Explorer's material track. Editor authoring review, Decay access and
in-tree game adoption remain open.

Final 960×540 controls change 1,449 native pixels and 1,451 Chromium pixels,
with maximum channel difference 45 in both. Both browser controls fetch all six
assets and reach ready without runtime/GPU errors. Captures were visually
inspected. Local checks pass 698 scoped tests plus 11 catalog tests (709 unique),
including 43 GPU tests repeated with required Vulkan/timestamps; native Clippy,
WASM, authored-color capture, formatting and file-size gates pass. Catalog
regeneration produces no changes. The fixed 300-prop on/off benchmark reconfirms
602/301 draws, 603/301 passes and 138,032/115,568 uniform bytes with valid GPU
timing. These are correctness/counter checks, not a performance improvement.

### Face visibility

`sindri.material.sidedness` is a checked choice: `inherit` (the default),
`single`, or `double`. It applies to the owning cube, inline surface, cached
voxel sections, or every primitive of an imported model. Other entities and
children retain their own settings. Unknown spellings and nonstring values
reject the component. The editor exposes the choice through checked commands
and undo/redo, using the same registration as generated component documentation.

```json
"sindri.material": {
  "base_color": [0.85, 0.4, 0.06, 1.0],
  "roughness": 0.7,
  "sidedness": "double"
}
```

`inherit` preserves each imported primitive's authored `doubleSided`; other
world geometry is single-sided. Explicit `single` can override an imported
double-sided primitive. Removing the component restores inherited visibility
alongside its existing factor restoration. Existing numeric-only overrides keep
their prior behavior because omitted sidedness inherits. Alpha masks, texture
ownership and legacy-look opt-in are unchanged.

`FramePass.sidedness` carries renderer-owned `MaterialSidedness` independently
of numeric factors. Both color and shadow encoding reset it for every pass.
Textured pipelines select front-face orientation and culling independently:
reflections preserve the outward side, and double-sided draws disable culling.
Visible backs flip interpolated authored normals toward the viewed side; flat
geometric normals already face the viewer. Imported color/shadow shaders use
the resolved policy with their existing orientation and normal logic.

Changing sidedness does not replace geometry, remesh voxels or upload model
assets. Two additional color and two additional shadow pipelines per textured
renderer cover double-sided reflected/unreflected draws. Uniform layouts,
draw/pass counts and per-draw queue writes are unchanged. This is face visibility,
not blended transparency, cutout-shadow unification or shared texture bindings.

GPU readbacks exercise inherited/forced/restored model settings, lit backs of
transient/retained/cached surfaces, a cube viewed from inside, reflected geometry,
matching directional-shadow coverage and geometry residency. Scene tests cover
checked removal/undo/redo and voxel overrides without remeshing. Lighting Lab
adds two back-facing orange panels: `Single-sided panel` is hidden from its
camera; `Double-sided panel` is visible and receives lighting on its back.
The `prepare-lighting3d.py --single-sided` control changes only the latter's
material policy. Native and real WebGPU capture comparisons require the visible
orange panel to disappear in that control. This is feature proof for the planned
Explorer material track; in-tree game adoption remains open.

Native review of the paired panels measures 3,550 changed/orange pixels at
960×540 when the double-sided control becomes single-sided. The 300-prop
baseline remains byte-identical, with 602/301 shadow-on/off draws and no
settled geometry uploads; offscreen and native-editor authored-color checks pass.
In a scratch Lighting Lab project, the editor choice hides/restores the panel,
Undo/Redo restores each policy, and saving and reopening preserves `single`.
Native Play reaches the existing lighting goal with that saved policy.

Real Chromium/WebGPU delivery measures the same 3,550 changed/orange pixels.
The lights-off control removes 91,020 brightened pixels, including 5,059 red
and 69,820 blue pixels; all three Lighting Lab exports fetch their six assets,
exercise the live point/spot phases and reach the scripted goal without GPU or
runtime errors. The existing browser benchmark remains byte-identical.

---

Source: `docs/daynight.md`

## Scripted day/night and mounted lights

`examples/daynight` is a feature example for Explorer and external Low Tide 3D's
planned lighting track. It reuses Model Collision Lab's self-contained GLBs and
the shared runtime, material, lighting and shadow paths. In-tree game adoption
remains open.

The Camera's checked Decay script owns a configurable clock. A day defaults to
twelve simulation seconds, starting at 06:00. Smooth sine-based daylight blends
the directional sun's color/intensity, ambient intensity/color and background.
The sun rotates continuously and is disabled below the horizon. A moving,
turning imported prop carries a work spotlight and a blue point light, which
fade in as daylight fades out. Sun and carrier motion continue after the first
completed day reports the ready goal.

`day_seconds` is clamped to at least one; `start_hour` wraps over 24 hours.
`preview_hour >= 0` holds a specific hour for authoring and comparisons; -1 runs
the clock. `move_carrier` and `night_lights` independently control movement and
illumination. These are ordinary exported project properties. There is no new
host API or engine clock, and no generated API files need regeneration.

The project regression opens the real scene/assets, compiles the script and
plays the default clock and a two-second day starting at noon. Both visit
sunrise, midday, sunset and night, report the goal once and keep moving the sun
and carrier afterward. At night extraction resolves both composed local-light
positions while the directional sun is absent, and both models remain in the
render frame. Frozen-hour tests verify fixed model poses, changed sun direction
and independent night-light enablement.

The native and browser controls isolate rendering changes:

| Control | What changes | What stays fixed |
| --- | --- | --- |
| Live cycle | Clock, sun, environment, carrier and mounted lights | One running project and its retained assets |
| 10:00 / 14:00, each shadows on/off | Scripted sun direction and shadow visibility | Model poses, camera and matching per-hour lighting/environment |
| Midnight lights on/off | Mounted local illumination | Sun disabled, model poses, ambient and background |

Shadow comparisons first find shadowed pixels on the imported green ground in
each same-hour on/off pair. They require shadow coverage to leave and enter
different receiver pixels, and the horizontal centroid to move between hours.
Changes in model shading alone cannot satisfy this check. Midnight comparisons
require more than 1,000 surface pixels to brighten while preserving the sky.
The continuous browser run observes all four phase messages and the cycle goal.
Native captures sample fixed simulation times; browser color comparisons use
frozen-hour previews so screenshot scheduling cannot race past sunset. These
verify distinct sky phases and a darker night. CI retains all controls and phase
previews for native and real WebGPU Chromium.

Native frozen-hour controls remove 1,740 and add 2,158 shadowed receiver pixels,
moving their centroid from x=480.54 to x=495.21. Mounted night lights brighten
66,632 pixels with the sun off. Native cycle skies progress through RGB
[131,114,134], [97,153,196], [138,100,110] and [22,29,53].

This closes feature-level day/night and dynamic sun-shadow integration; it does
not close the Milestone 8 game acceptance. Fog/post delivery and controls,
local-light shadows, skyboxes/atmosphere, stars and weather remain separate.


Local validation passes 68 game tests (including the two new real-project
regressions), 11 catalogue checks and three documented-surface checks. Typed
preflight reports zero errors/reminders; warning-denied workspace Clippy and
WASM checks, formatting and file-size checks pass. Browser shadow controls remove
1,737 and add 2,095 receiver pixels, moving their centroid from x=481.08 to
x=495.26. Mounted lights brighten 66,494 browser pixels with the sun disabled.
The continuous browser project reaches all phases and its goal with four asset
fetches and no runtime/GPU errors. Frozen browser skies are RGB [131,113,133],
[97,153,196], [138,100,110] and [22,29,53]. Native editor Play visits all four
phases and reaches ready; setting Preview hour to 17 through the inspector saves
and holds the warm sunset. Stop/save restores seven entities, the original
environment/lights and carrier pose, while retaining the authored preview value.
The final project also reopens and reaches the default cycle goal after authoring
the Camera's resting view; Stop restores its authored state.
