Source: https://doc.babylonjs.com/lite/architecture/03-lights

# Module: Lights

> Package path: `packages/babylon-lite/src/light/`

## Purpose

Provides plain-data light definitions for **hemispheric**, **directional**, **point**, and **spot** light types, plus a shared infrastructure layer (`light-base.ts`, `types.ts`) and a scene-owned lights UBO packing system (`render/lights-ubo.ts`, `scene/scene-light-state.ts`). Following Babylon Lite's "pillar 4b" principle, lights are stateless data objects with no scene references.

Factory functions create light objects with sensible defaults; callers add them to scenes or pass them to material setup functions. Each light carries:

- Push-based dirty tracking via `ObservableVec3` for positions/directions
- World-matrix state with parent support (inherited from `light-base.ts`)
- Shared UBO writer (`_writeLightUbo`) for the scene lights UBO system
- Version tracking (`_lightVersion` plus `worldMatrixVersion`) so scalar and transform changes,
  including inherited parent transforms, refresh the shared light UBO

PBR no longer uses per-light extension registration or light fields in `SceneUniforms`. Standard, PBR, and NodeMaterial consume the scene-owned `LightsUniforms` UBO at `@group(0) @binding(1)`. Per-mesh UBOs carry material-independent light selection (`lc` plus packed `li: array<vec4<u32>, ceil(MAX_LIGHTS / 4)>`) computed from `LightBase.includedOnlyMeshes` / `excludedMeshes` and `includedOnlyMeshIds` / `excludedMeshIds` (see [Per-mesh light filtering](#per-mesh-light-filtering)); shaders index the scene lights array through those mesh indices. Exactly one eligible non-shadow PBR light uses `material/pbr/fragments/singlelight-wgsl.ts`; multiple lights or any shadow receiver use `material/pbr/fragments/multilight-wgsl.ts`.

---

## Public API Surface

### Shared Types (`types.ts`)

```typescript
/** Shared base for all light types. Provides pipeline integration callbacks. */
export interface LightBase extends IWorldMatrixProvider, IParentable {
    readonly lightType: string;
    children: SceneNode[];
    excludedMeshes?: ReadonlySet<Mesh>;
    includedOnlyMeshes?: ReadonlySet<Mesh>;
    excludedMeshIds?: ReadonlySet<string>;
    includedOnlyMeshIds?: ReadonlySet<string>;
    shadowGenerator?: ShadowGenerator;
    parent: IWorldMatrixProvider | null;
    readonly worldMatrix: Mat4;
    readonly worldMatrixVersion: number;
}

/** @internal */
export interface LightBaseInternal extends LightBase {
    readonly _writeLightUbo?: (data: Float32Array, offset: number) => void;
    readonly _lightVersion: number;
}

export let MAX_LIGHTS = 16;
export function setMaxLights(n: number): void;
export const LIGHT_ENTRY_FLOATS = 16; // 4 × vec4 = 64 bytes per light
```

### Runtime Intensity Updates (`set-light-intensity.ts`)

```typescript
export function setLightIntensity(light: LightBase & { intensity: number }, intensity: number): void;
export function setLightDiffuseColor(light: LightBase & { diffuse: [number, number, number] }, color: readonly [number, number, number]): void;
```

Updates a finite scalar intensity and bumps the light-only version counter so the shared lights UBO
is refreshed without invalidating the light's world matrix. Assigning an intensity during initial
scene setup remains valid because the first UBO upload reads the final value; runtime changes use
this setter. `setLightDiffuseColor()` provides the same invalidation for directional, point, and
spot-light diffuse RGB changes.

### Light Base (`light-base.ts`)

```typescript
/** Create the SceneNode transform and version state shared by all light types. */
export function createLightBase(position: readonly [number, number, number]): {
    node: SceneNode;
    lvs: LightVersionState;
};

/** Add light-specific state to a SceneNode and return the same object. */
export function applyLightBase<R>(node: SceneNode, target: object, lvs?: LightVersionState): R;

export { ObservableVec3 } from "../math/observable-vec3.js";
```

Lights are full SceneNodes, with the same position, rotation, scaling, parent, children,
and world-matrix behavior as meshes and transform nodes. The local `direction` is transformed
by that world matrix, so direct quaternion/Euler writes orient the rendered light while
preserving the existing direction API. `setParent` therefore uses the standard SceneNode path without a
light-specific runtime branch. UBO writers normalize the world direction so parent scale
cannot alter directional intensity or spotlight cone tests. `_lightVersion` includes the
world matrix version, so ancestor motion refreshes GPU light data. Shadow builders likewise
derive light position/direction from `worldMatrix`.

### Directional Light (`directional-light.ts`)

```typescript
export interface DirectionalLight extends LightBase {
    readonly lightType: "directional";
    direction: ObservableVec3;
    position: ObservableVec3;
    diffuse: [number, number, number];
    specular: [number, number, number];
    intensity: number;
}

export function createDirectionalLight(
    direction: [number, number, number],
    intensity?: number // Default: 1
): DirectionalLight;
```

**Default values:**

| Property  | Default                        |
| --------- | ------------------------------ |
| lightType | `'directional'`                |
| direction | _(parameter)_                  |
| position  | `(0, 0, 0)` via ObservableVec3 |
| diffuse   | `[1, 1, 1]`                    |
| specular  | `[1, 1, 1]`                    |
| intensity | `1`                            |

### Point Light (`point-light.ts`)

```typescript
export interface PointLight extends LightBase {
    readonly lightType: "point";
    position: ObservableVec3;
    diffuse: [number, number, number];
    specular: [number, number, number];
    intensity: number;
    range: number;
}

export function createPointLight(
    position: [number, number, number],
    intensity?: number // Default: 1.0
): PointLight;
```

**Default values:**

| Property  | Default                          |
| --------- | -------------------------------- |
| lightType | `'point'`                        |
| position  | _(parameter)_ via ObservableVec3 |
| diffuse   | `[1, 1, 1]`                      |
| specular  | `[1, 1, 1]`                      |
| intensity | `1.0`                            |
| range     | `Number.MAX_VALUE`               |

**Local matrix:** `createTranslationMat4(position.x, position.y, position.z)` — position only, no orientation.

### Hemispheric Light (`hemispheric.ts`)

```typescript
export interface HemisphericLight extends LightBase {
    readonly lightType: "hemispheric";
    direction: ObservableVec3;
    intensity: number;
    diffuseColor: [number, number, number];
    specularColor: [number, number, number];
    groundColor: [number, number, number];
}

export function createHemisphericLight(
    direction?: [number, number, number], // Default: [0, 1, 0]
    intensity?: number // Default: 1.0
): HemisphericLight;
```

**Default values:**

| Property      | Default                        |
| ------------- | ------------------------------ |
| lightType     | `'hemispheric'`                |
| direction     | `(0, 1, 0)` via ObservableVec3 |
| intensity     | `1.0`                          |
| diffuseColor  | `[1, 1, 1]`                    |
| specularColor | `[1, 1, 1]`                    |
| groundColor   | `[0, 0, 0]`                    |

### Spot Light (`spot-light.ts`)

```typescript
export interface SpotLight extends LightBase {
    readonly lightType: "spot";
    position: ObservableVec3;
    direction: ObservableVec3;
    /** Full cone angle in radians. */
    angle: number;
    /** Falloff exponent — higher = sharper spotlight. */
    exponent: number;
    diffuse: [number, number, number];
    specular: [number, number, number];
    intensity: number;
    range: number;
}

export function createSpotLight(
    position: [number, number, number],
    direction: [number, number, number],
    angle: number,
    exponent: number,
    intensity?: number // Default: 1.0
): SpotLight;
```

**Default values:**

| Property  | Default                          |
| --------- | -------------------------------- |
| lightType | `'spot'`                         |
| position  | _(parameter)_ via ObservableVec3 |
| direction | _(parameter)_ via ObservableVec3 |
| angle     | _(parameter)_                    |
| exponent  | _(parameter)_                    |
| diffuse   | `[1, 1, 1]`                      |
| specular  | `[1, 1, 1]`                      |
| intensity | `1.0`                            |
| range     | `Number.MAX_VALUE`               |

**Local matrix:** Uses `localMatrixFromDirection(direction, position)` — both orientation and position.

---

## Internal Architecture

### Data Structures

All lights are plain JavaScript objects (POJOs) with `Object.defineProperties`-based world-matrix accessors — no classes, no GPU resources. The scene owns one GPU `LightsUniforms` buffer for `scene.lights`; materials only declare/read the fixed group-0 binding.

### Light Base Infrastructure (`light-base.ts`)

Every light factory:

1. Calls `createLightBase(getLocalMatrix)` which returns `{ wm, onDirty }`:
    - `wm`: `WorldMatrixAccessors` — provides `getWorldMatrix()`, `getWorldMatrixVersion()`, `markLocalDirty()`, and `parent` get/set
    - `onDirty`: callback that calls `wm.markLocalDirty()` — passed to `ObservableVec3` constructors
2. Builds the light data object with an `_writeLightUbo` callback
3. Calls `applyWorldMatrixAccessors(target, wm)` which uses `Object.defineProperties` to add `parent`, `worldMatrix`, and `worldMatrixVersion` as getters/setters

This pattern eliminates duplicated world-matrix boilerplate across all light types.

### Light Type Discrimination (Standard Material)

Each light writes its type flag at `data[offset + 3]` in `_writeLightUbo`:

| `vLightData.w` | Light Type  | Position/Direction Source                 |
| -------------- | ----------- | ----------------------------------------- |
| `0`            | Point       | xyz = world position (worldMatrix col 3)  |
| `1`            | Directional | xyz = world direction (worldMatrix col 2) |
| `2`            | Spot        | xyz = world position (worldMatrix col 3)  |
| `3`            | Hemispheric | xyz = world direction (worldMatrix col 2) |

### Lights UBO Layout (`render/lights-ubo.ts`)

Standard, PBR, and NodeMaterial pipelines use a shared scene lights UBO supporting up to `MAX_LIGHTS = 16` packed scene lights by default. `setMaxLights(n)` may adjust the cap before pipelines/UBOs are created. Unlike Babylon.js's default `maxSimultaneousLights = 4` per material, Babylon Lite's `MAX_LIGHTS` is the total scene-wide packed-light capacity.

The frame/pass bind group layout is:

| Group | Binding | Owner                                             | Contents                                                              |
| ----- | ------- | ------------------------------------------------- | --------------------------------------------------------------------- |
| 0     | 0       | `RenderTask`                                      | Per-pass `SceneUniforms` (camera, fog, image processing, environment) |
| 0     | 1       | `SceneContextInternal` via `scene-light-state.ts` | Scene-wide `LightsUniforms` packed from `scene.lights`                |

Material/mesh bind groups never contain light buffers. Mesh UBOs append:

```wgsl
lc: u32,
li: array<vec4<u32>, ceil(MAX_LIGHTS / 4)>,
```

Each packed index addresses `lights.lights[index]` in the scene-wide UBO (`li[i / 4u][i % 4u]`). `render/mesh-light-selection.ts` computes this list per mesh with `lightAffectsMesh(light, mesh)`, skipping lights the mesh is filtered out of by either pair of lists (see [Per-mesh light filtering](#per-mesh-light-filtering)).

**Default total UBO size:** `getLightsUboSize() = 16 + MAX_LIGHTS × 64` bytes (1040 bytes when `MAX_LIGHTS = 16`)

**Layout:**

| Offset (bytes) | Size | Content                                 |
| -------------- | ---- | --------------------------------------- |
| 0–3            | 4B   | `count` (u32) — number of active lights |
| 4–15           | 12B  | padding (3 × u32)                       |
| 16–79          | 64B  | Light 0 entry (4 × vec4)                |
| 80–143         | 64B  | Light 1 entry (4 × vec4)                |
| 144–207        | 64B  | Light 2 entry (4 × vec4)                |
| 208–271        | 64B  | Light 3 entry (4 × vec4)                |

**Per-light entry layout (LIGHT_ENTRY_FLOATS = 16, 64 bytes):**

| Float Index | Directional (w=1)    | Point (w=0)          | Spot (w=2)           | Hemispheric (w=3)         |
| ----------- | -------------------- | -------------------- | -------------------- | ------------------------- |
| [0–2]       | direction (col2)     | position (col3)      | position (col3)      | direction (col2)          |
| [3]         | 1 (type flag)        | 0 (type flag)        | 2 (type flag)        | 3 (type flag)             |
| [4–6]       | diffuse × intensity  | diffuse × intensity  | diffuse × intensity  | diffuseColor × intensity  |
| [7]         | MAX_VALUE (range)    | range                | range                | _(unused)_                |
| [8–10]      | specular × intensity | specular × intensity | specular × intensity | specularColor × intensity |
| [11]        | _(unused)_           | _(unused)_           | exponent             | _(unused)_                |
| [12–14]     | _(unused)_           | _(unused)_           | direction (col2)     | groundColor × intensity   |
| [15]        | _(unused)_           | _(unused)_           | cos(angle/2)         | _(unused)_                |

### UBO Functions (`lights-ubo.ts`)

```typescript
/** Fill a Float32Array with standard light data. */
export function fillLightsData(data: Float32Array, lights: readonly LightBase[]): void;

/** Current lights UBO byte size for the active MAX_LIGHTS value. */
export function getLightsUboSize(): number;

/** Create a new lights UBO from all compatible lights. */
export function writeLightsUBO(engine: EngineContextInternal, lights: readonly LightBase[]): GPUBuffer;

/** Refresh an existing lights UBO with current light state. */
export function refreshLightsUBO(engine: EngineContextInternal, buffer: GPUBuffer, lights: readonly LightBase[], scratch: Float32Array): void;
```

**`fillLightsData` algorithm:**

1. Zero the entire Float32Array
2. Iterate lights, skip those without `_writeLightUbo`, stop at `MAX_LIGHTS`
3. Call each light's `_writeLightUbo(data, headerFloats + count * LIGHT_ENTRY_FLOATS)`
4. Write `count` into the first u32 slot via `Uint32Array` view

### Per-mesh light filtering

Two pairs of lists on `LightBase` restrict which meshes a light reaches. `render/mesh-light-selection.ts`
exports the single predicate both the packed selection and the material feature mirrors use:

```typescript
export function lightAffectsMesh(light: LightBase, mesh: Mesh): boolean;
```

**Object-identity lists.** `excludedMeshes` and `includedOnlyMeshes` are `ReadonlySet<Mesh>`. Both apply:
a non-empty `includedOnlyMeshes` restricts the light to those meshes, and `excludedMeshes` then subtracts
from the result. This matches Babylon.js `Light.canAffectMesh`.

**ID lists.** `excludedMeshIds` and `includedOnlyMeshIds` are `ReadonlySet<string>` matched against
`Mesh.id`, which only the `.babylon` loader writes. Their precedence differs and is deliberate: a
non-empty `includedOnlyMeshIds` wins outright and exclusions are not subtracted from it.

**Precedence.** The object lists are evaluated first. When `includedOnlyMeshes` is non-empty the ID lists
are not consulted at all; otherwise an object exclusion is final, and only an unexcluded mesh falls
through to the ID lists. A light with no list set takes an allocation-free path.

An ID cannot identify a mesh on its own, which is why the object lists exist: loading one asset twice
yields distinct meshes carrying the same asset-local ID, and a clone is spread from its source, so it
arrives with the source's ID. Compat clears `id` on a mesh clone for that reason.

**Cloning.** `copyLightBase` in `light/light-clone.ts` carries all four sets onto the clone, so a cloned
light keeps its filter rather than becoming unrestricted. The sets are shared by reference, not copied.

**Consistency with shader selection.** The selection is baked when a renderable is built: into the
shader's light-mode permutation, into the mesh UBO's packed indices, and into any material feature bits
that mirror the selection (`SceneLightingExtension._pbrFeatures(mesh)`, used by rectangular area lights).
`buildPbrRenderables` therefore alternates scanning the selection and loading what is missing until a scan
finds nothing missing, so the light WGSL it composes against is always a superset of what the final
selection needs. The loop ends on a scan rather than on an await, which is what makes the guarantee hold
against an edit landing inside the loading itself, and it terminates because loading only ever adds over a
finite set of paths.

**Lifecycle.** The per-frame updater only revisits the selection when the light count or the mesh's world
matrix changes, so an edit under a stable light count needs a renderable rebuild. Compat drives that:

1. `Light.excludedMeshes` / `includedOnlyMeshes` hand out the light's own live array with `push` and
   `splice` hooked, the two methods Babylon.js hooks. One hook per array, reading a subscriber set, so an
   array shared between lights carries a single wrapper.
2. Publishing is coalesced onto the microtask queue, and the rebuild is coalesced per scene. Empty lists
   publish `undefined`.
3. `Scene._hasStarted` turns true several awaits before `registerScene` sets `_built`, and
   `rebuildSceneRenderables` is a documented no-op until then. An edit inside that window is held on the
   engine's late-work queue and applied once registration finishes, so it reaches groups already built.
4. A light is released from an array when its list is replaced, when the light is disposed, and when its
   scene is disposed (through `onSceneDispose`), leaving other live consumers of that array subscribed.
   The scene registration is made synchronously when a list is handed out or edited, not by the publish
   queued behind it, so a scene disposed in the same tick still releases the light, and an array shared
   out of one light into another scene's light does not keep the first light's scene reachable. A list
   touched when the scene is already disposed releases immediately, since a disposal callback registered
   then would never run.
5. Retiring a mesh splices it out of every scene light's two lists, as Babylon.js does in its own
   `AbstractMesh.dispose`. A dead mesh left in an inclusion would keep that restriction alive and
   leave every surviving mesh unlit by the light. The splice goes through the hook, so the republish and
   the rebuild follow the ordinary edit path and a list shared between lights notifies all of them. It
   hangs off wrapper retirement rather than node disposal, so a mesh an `AssetContainer` tears down
   prunes as well.
6. The rebuild is skipped for a light held by a clustered container, which is outside the per-mesh light
   path. Babylon.js skips its own resync for the same reason.

### PBR Light Shader Paths

PBR materials consume the same packed `LightEntry` layout as Standard materials. The PBR renderable builder decides which WGSL helper to import once per scene:

| Condition                                                       | WGSL helper                                  | Behavior                                                                                                 |
| --------------------------------------------------------------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| Mesh has exactly one eligible light and no shadow receiver path | `material/pbr/fragments/singlelight-wgsl.ts` | Non-looping direct-light code specialized by that light's `lightType`; reads `lights.lights[mli(0u)]`    |
| Mesh has multiple eligible lights, or any shadow receiver path  | `material/pbr/fragments/multilight-wgsl.ts`  | Generic `computePbrLight()` + loop over `mesh.lc`; shadow fragment writes per-scene-light shadow factors |

Supported PBR light types are hemispheric, directional, point, and spot. PBR materials default to physical inverse-square point/spot falloff. Materials with `usePhysicalLightFalloff: false` use Babylon's Standard-style falloff instead: linear range attenuation plus spot cone exponent attenuation.

### Shader Lighting Math (Standard Material)

(Defined in `standard-textured.fragment.wgsl`, consumed via lights UBO)

**Point light attenuation:**

```
direction = lightPosition - fragmentPosition
attenuation = max(0, 1 - length(direction) / range)
lightVector = normalize(direction)
```

**Directional light:**

```
lightVector = normalize(-lightDirection)
attenuation = 1.0
```

**Spot light attenuation:**

```
lightVector = normalize(lightPosition - fragmentPosition)
attenuation = max(0, 1 - length(lightPosition - fragmentPosition) / range)
cosAngle = dot(normalize(lightDirection), -lightVector)
spotFalloff = max(0, cosAngle - cosHalfAngle) ^ exponent
attenuation *= spotFalloff
```

**Hemispheric light:**

```
lightVector = normalize(lightDirection)
NdotL = dot(normal, lightVector) * 0.5 + 0.5  // wrapped diffuse
diffuse = mix(groundColor, diffuseColor, NdotL) * intensity
```

**Diffuse (Lambertian):**

```
ndl = max(0, dot(normal, lightVector))
diffuse = ndl * lightDiffuseColor * attenuation
```

**Specular (Blinn-Phong):**

```
halfVector = normalize(viewDir + lightVector)
specComp = pow(max(0, dot(normal, halfVector)), max(1, glossiness))
specular = specComp * lightSpecularColor * attenuation
```

---

## Pipeline Configuration

Light modules do not create GPU pipelines. They produce plain data consumed by material pipelines.

The lights UBO (`render/lights-ubo.ts`) creates a single `GPUBuffer` with `UNIFORM | COPY_DST` usage. `SceneContextInternal._lightGpuState` stores it on the scene, refreshes it per-frame through `refreshSceneLightsUBO()`, and recreates it if the active `MAX_LIGHTS` size changes. The default size is 1040 bytes, but the size follows `MAX_LIGHTS`.

---

## Shader Logic

Light modules do not contain shaders. Lighting computation lives in material shader modules: Standard and NodeMaterial loop over `mesh.lc` / `mli(i)` into the group-0 lights UBO, and PBR dynamically imports either `singlelight-wgsl.ts` or `multilight-wgsl.ts` from `material/pbr/fragments/`.

---

## State Machine / Lifecycle

### Light Creation

```typescript
// Directional
const light = createDirectionalLight([0, -1, 0], 1.5);
light.position.set(10, 20, 10); // triggers onDirty → markLocalDirty

// Point
const point = createPointLight([5, 3, 0], 2.0);
point.range = 100;

// Hemispheric
const hemi = createHemisphericLight([0, 1, 0], 0.7);
hemi.groundColor = [0.1, 0.1, 0.1];

// Spot
const spot = createSpotLight([0, 10, 0], [0, -1, 0], Math.PI / 3, 2.0, 1.5);
spot.angle = Math.PI / 4;
```

### Mutation & Dirty Tracking

- Setting `direction.x`, `direction.y`, `direction.z` or calling `direction.set(x,y,z)` on any `ObservableVec3` triggers `onDirty()` → `wm.markLocalDirty()` → increments `worldMatrixVersion`
- `worldMatrix` getter lazily recomputes from `getLocalMatrix()` and parent chain when dirty
- `_writeLightUbo` reads from `worldMatrix` (which auto-resolves parent transforms)

### Lights UBO Lifecycle

1. **Creation:** `ensureSceneLightState(engine, scene)` allocates a `getLightsUboSize()` UBO and stores it on `SceneContextInternal._lightGpuState`.
2. **Group-0 binding:** every `RenderTask` binds its task-owned scene UBO at binding 0 and the scene-owned lights UBO at binding 1.
3. **Per-frame refresh:** `refreshSceneLightsUBO(engine, scene)` compares the aggregate light version and light count, then writes the shared UBO only when needed.
4. **Light filtering:** only lights with `_writeLightUbo` defined are packed; up to `MAX_LIGHTS`.
5. **Resize for cap changes:** if `MAX_LIGHTS` changes and the UBO byte size changes, `ensureSceneLightState()` destroys/recreates the scene light buffer and render-pass tasks rebuild their group-0 bind group.
6. **Mesh selection:** material renderables write per-mesh `lc` and packed `li` indices into the mesh UBO; this selection respects both the object-identity lists and the ID lists.

**No GPU resources are created by light modules.** Light packing and scene-owned GPU state live in `lights-ubo.ts`; materials only own shaders, mesh/material UBOs, textures, and bind groups.

---

## Babylon.js Equivalence Map

| Babylon Lite                                  | Babylon.js                                                    |
| --------------------------------------------- | ------------------------------------------------------------- |
| `createDirectionalLight(dir, intensity)`      | `new DirectionalLight(name, dir, scene)`                      |
| `DirectionalLight.direction` (ObservableVec3) | `DirectionalLight.direction` (Vector3)                        |
| `DirectionalLight.position` (ObservableVec3)  | `DirectionalLight.position` (Vector3)                         |
| `DirectionalLight.diffuse`                    | `DirectionalLight.diffuse`                                    |
| `DirectionalLight.specular`                   | `DirectionalLight.specular`                                   |
| `DirectionalLight.intensity`                  | `DirectionalLight.intensity`                                  |
| `createPointLight(pos, intensity)`            | `new PointLight(name, pos, scene)`                            |
| `PointLight.range = Number.MAX_VALUE`         | `PointLight.range` (default very large)                       |
| `createHemisphericLight(dir, intensity)`      | `new HemisphericLight(name, dir, scene)`                      |
| `HemisphericLight.diffuseColor`               | `HemisphericLight.diffuse`                                    |
| `HemisphericLight.specularColor`              | `HemisphericLight.specular`                                   |
| `HemisphericLight.groundColor`                | `HemisphericLight.groundColor`                                |
| `createSpotLight(pos, dir, angle, exp, int)`  | `new SpotLight(name, pos, dir, angle, exp, scene)`            |
| `SpotLight.angle`                             | `SpotLight.angle`                                             |
| `SpotLight.exponent`                          | `SpotLight.exponent`                                          |
| `SpotLight.range`                             | `SpotLight.range`                                             |
| `LightBase.lightType` string discriminator    | Class hierarchy + `getTypeID()` (internal)                    |
| `LightBase.excludedMeshes`                    | `Light.excludedMeshes`                                        |
| `LightBase.includedOnlyMeshes`                | `Light.includedOnlyMeshes`                                    |
| `LightBase.excludedMeshIds`                   | `.babylon` loader light lists (no Babylon.js equivalent)      |
| `LightBase.includedOnlyMeshIds`               | `.babylon` loader light lists (no Babylon.js equivalent)      |
| `LightBase.shadowGenerator`                   | `Light.getShadowGenerator()`                                  |
| `LightBase.parent` (IWorldMatrixProvider)     | `Light.parent` (TransformNode)                                |
| Plain data objects (no scene ref)             | Class instances with scene reference                          |
| `ObservableVec3` with dirty callback          | `Vector3` + `_markAsDirty()` pattern                          |
| `localMatrixFromDirection()`                  | `Light._buildUniformLayout()` (internal)                      |
| Light type w flag (0/1/2/3)                   | Internal type system (`LIGHTTYPEID_*`)                        |
| `MAX_LIGHTS = 16` scene-wide cap              | `maxSimultaneousLights` (default 4 per material)              |
| `fillLightsData()` / `writeLightsUBO()`       | Babylon's internal light UBO building                         |
| `refreshLightsUBO()`                          | Per-frame light uniform update                                |
| `singlelight-wgsl.ts` / `multilight-wgsl.ts`  | PBR shader includes (`pbrDirectLightingSetupFunctions`, etc.) |

### Key Differences from Babylon.js

1. **No scene reference** — Babylon Lite lights are POJOs with world-matrix accessors; Babylon.js lights are class instances that register with a Scene.
2. **`lightType` string discriminator** — Instead of class hierarchy, each light has a `lightType` string literal (`'directional'`, `'point'`, `'spot'`, `'hemispheric'`).
3. **`ObservableVec3`** — Direction/position use observable vectors with dirty callbacks, replacing Babylon's Vector3 + manual dirty tracking.
4. **Shared lights UBO** — Standard and PBR pack up to `MAX_LIGHTS` scene lights into one UBO; Babylon.js uses material-scoped light defines/uniforms and defaults to 4 simultaneous lights per material.
5. **Tree-shakable PBR light code** — PBR imports the one-light or multi-light WGSL helper only when needed; Babylon.js includes broad light shader support.
6. **No shadow caster list on lights or generators** — Shadow casters are scene/frame-graph `ShadowTask` inputs registered for a generator. Lights only reference their shadow generator via `shadowGenerator` property.

---

## Dependencies

### types.ts

- `../math/types.js` — `Mat4`
- `../scene/parentable.js` — `IWorldMatrixProvider`, `IParentable`
- `../shadow/shadow-generator.js` — `ShadowGenerator` (type-only import)

### light-base.ts

- `../math/types.js` — `Mat4`
- `../scene/parentable.js` — `IWorldMatrixProvider`
- `../scene/world-matrix-state.js` — `createWorldMatrixState`, `WorldMatrixAccessors`
- `../math/observable-vec3.js` — `ObservableVec3` (re-exported)

### light-matrix.ts

- `../math/types.js` — `Mat4`

### directional-light.ts

- `./types.js` — `LightBase`
- `./light-base.js` — `createLightBase`, `applyWorldMatrixAccessors`, `ObservableVec3`
- `./light-matrix.js` — `localMatrixFromDirection`

### point-light.ts

- `./types.js` — `LightBase`
- `../math/mat4.js` — `createTranslationMat4`
- `./light-base.js` — `createLightBase`, `applyWorldMatrixAccessors`, `ObservableVec3`

### hemispheric.ts

- `./types.js` — `LightBase`
- `./light-base.js` — `createLightBase`, `applyWorldMatrixAccessors`, `ObservableVec3`
- `./light-matrix.js` — `localMatrixFromDirection`

### spot-light.ts

- `./types.js` — `LightBase`
- `./light-base.js` — `createLightBase`, `applyWorldMatrixAccessors`, `ObservableVec3`
- `./light-matrix.js` — `localMatrixFromDirection`

### lights-ubo.ts

- `../light/types.js` — `LightBase`, `LightBaseInternal`, `MAX_LIGHTS`, `LIGHT_ENTRY_FLOATS`

---

## Test Specification

1. **Directional light defaults** — `createDirectionalLight([0, -1, 0])` returns `lightType: 'directional'`, direction `(0,-1,0)`, position `(0,0,0)`, diffuse `[1,1,1]`, specular `[1,1,1]`, intensity `1`.
2. **Point light defaults** — `createPointLight([5, 3, 0])` returns `lightType: 'point'`, position `(5,3,0)`, diffuse `[1,1,1]`, specular `[1,1,1]`, intensity `1`, range `Number.MAX_VALUE`.
3. **Hemispheric light defaults** — `createHemisphericLight()` returns `lightType: 'hemispheric'`, direction `(0,1,0)`, intensity `1`, diffuseColor `[1,1,1]`, specularColor `[1,1,1]`, groundColor `[0,0,0]`.
4. **Spot light defaults** — `createSpotLight([0,10,0], [0,-1,0], PI/3, 2)` returns `lightType: 'spot'`, diffuse `[1,1,1]`, specular `[1,1,1]`, intensity `1`, range `Number.MAX_VALUE`.
5. **Custom intensity** — `createDirectionalLight([1,0,0], 2.5).intensity` should be `2.5`.
6. **Mutability** — All properties should be directly assignable. ObservableVec3 properties support `.x`, `.y`, `.z` setters and `.set(x,y,z)`.
7. **Dirty tracking** — Setting `direction.x = 5` should increment `worldMatrixVersion`.
8. **World transform** — Directional light's local direction should be normalized after transformation by its world matrix.
9. **Parent support** — Setting `light.parent` should affect `worldMatrix` computation.
10. **Light type flags** — `_writeLightUbo` should set w=0 (point), w=1 (directional), w=2 (spot), w=3 (hemispheric).
11. **Spot UBO packing** — Spot light writes exponent at [11], direction at [12–14], cos(angle/2) at [15].
12. **Light UBO packing** — For directional light: `lightData.w = 1`, colors premultiplied by intensity.
13. **Light UBO packing** — For point light: `lightData.w = 0`, `lightDiffuse.a = range`.
14. **Lights UBO size** — `getLightsUboSize() = 272` bytes by default (16 header + 4 × 64).
15. **fillLightsData count** — With 6 lights, only first 4 with `_writeLightUbo` are packed.
16. **PBR single-light selection** — One non-shadow light imports `singlelight-wgsl.ts`, not the generic multi-light loop.
17. **PBR multi-light selection** — Multiple lights or shadow receivers import `multilight-wgsl.ts`.

---

## File Manifest

| File                             | Role                                                                                                                                |
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `src/light/types.ts`             | `LightBase`, `LightBaseInternal` interfaces; `MAX_LIGHTS`, `setMaxLights()`, `LIGHT_ENTRY_FLOATS`                                   |
| `src/light/light-base.ts`        | `createLightBase()`, `applyWorldMatrixAccessors()` — shared world-matrix state factory; re-exports `ObservableVec3`                 |
| `src/light/light-matrix.ts`      | `localMatrixFromDirection()` — builds local 4×4 matrix from direction + position                                                    |
| `src/light/directional-light.ts` | `DirectionalLight` interface + `createDirectionalLight()` factory                                                                   |
| `src/light/point-light.ts`       | `PointLight` interface + `createPointLight()` factory                                                                               |
| `src/light/hemispheric.ts`       | `HemisphericLight` interface + `createHemisphericLight()` factory                                                                   |
| `src/light/spot-light.ts`        | `SpotLight` interface + `createSpotLight()` factory                                                                                 |
| `src/render/lights-ubo.ts`       | Shared lights UBO system — `getLightsUboSize()`, scene light GPU state, mesh light selection; packs up to `MAX_LIGHTS` scene lights |
