lighting
Scene-lighting base capability: ambient + directional ("sun") light and procedural-sky setup with modify-or-spawn, read-merge-write semantics, plus clear color, and the per-frame reads of the resolved lights. Identity is the component, never the entity name: the sun is the `Light…
lighting
Scene-lighting base capability: ambient + directional ("sun") light and procedural-sky setup with modify-or-spawn, read-merge-write semantics, plus clear color, and the per-frame reads of the resolved lights. Identity is the component, never the entity name: the sun is the Light the renderer's snapshot names as the directional holder, the ambient is the light-role component of kind "ambient", and the sky is the entity carrying a sky-role component. Name-based lookup, word resolution, and sky-material orchestration (presets, material swap) live in the lighting toolbox.
sun and ambient configuration patch the scene's resolved sun / ambient light components, spawning a carrying entity when the scene has none. A partial update (e.g. only intensity) reads the component's current fields first and merges the given fields on top, so unspecified fields keep their authored values instead of resetting to a default. sky writes the sky entity's ProceduralSky component the same way — any of its fields, plus enabled = false to remove the sky component.
Types
SunOpts—{ direction?, color?, intensity?, castsShadows? }.AmbientOpts—{ color?, intensity? }.ProceduralSkyOpts— ProceduralSky component fields, plusenabled = falseto remove the sky.SetupOpts—{ sun?, ambient?, sky?, clearColor? }.SetupResult—{ sun?, ambient?, sky? }, the entity ids each provided section resolved to.Sun—{ direction, color, intensity, castsShadows, entityId? }.Ambient—{ color, intensity, entityId? }.LightRow— one punctual light as the renderer resolved it.
Exports
Writes — each takes the opts above and answers with the entity the light resolved to:
M.setSun(opts: SunOpts) -> string— set the scene's sun, returning its entity id.M.setAmbient(opts: AmbientOpts) -> string— set the scene's ambient term, returning its entity id.M.applySetup(opts: SetupOpts) -> SetupResult— apply a lighting setup in one call. All fields optional — only provided fields change.clearColoris{r, g, b}.M.setProbeVolume(key: string, volume)/M.removeProbeVolume(key: string)— publish or retract a baked irradiance probe volume.
Reads — each takes no arguments, and answers from the resolved lighting state at a cost independent of scene size:
M.sun() -> Sun— the sun the frame is shaded by.M.ambient() -> Ambient— the ambient term the frame is shaded by.M.lightRows() -> { LightRow }— every punctual light the renderer resolved this frame.
Identity:
M.lightKind(componentType: string, component: any?) -> string?— the kind a light-role component names.M.lightOfKind(entityId: string, kind: string) -> any— the light-role component of that kind on one entity.
Usage
local lighting = require("@builtin::modules.api.engine.lighting")
lighting.setSun({ direction = { 0.3, -1, 0.2 }, intensity = 2.0 })
lighting.setAmbient({ intensity = 0.3 })
lighting.applySetup({
sun = { direction = { 0.3, -1, 0.2 }, intensity = 2.0 },
ambient = { intensity = 0.3 },
sky = { timeOfDay = 14 },
})
local sun = lighting.sun()
print(sun.intensity, sun.entityId)
A reader takes no arguments and a setter takes the opts table, so lighting.sun(opts) is refused with the name of the call that applies it.
Interface
What this asset declares: the schema it conforms to, what it exposes, and the rendered structured payload.
conforms to
zero/source-extract/v2modules/api/engine/lighting.module/init.luau Scene-lighting base capability: ambient + directional ("sun") light and procedural-sky setup with modify-or-spawn, read-merge-write semantics, plus clear color. `setSun`, `setAmbient` and `applySetup` write; `sun`, `ambient` and `lightRows` read the resolved lights every frame and each takes no arguments. Identity is the COMPONENT, never the entity name: the sun is the `Light` the renderer's snapshot names as the directional holder, the ambient is the light-role component of kind "ambient", and the sky is the entity carrying a sky-role component. Name-based lookup, word resolution, and sky-material orchestration live in the `lighting` toolbox.
leafType(identity: string) → string
The leaf of a component identity: "@builtin::components.SpotLight" is "SpotLight", the vocabulary the kind map and every consumer speak.
| arg | type | description |
|---|---|---|
| identity | string |
declaresKind(component: any) → boolean
Whether a component declares a `kind` field of its own. Asked of the component through its field descriptors, which answer for any component — so the question is settled before the field is read, and a component that declares none is never asked for it.
| arg | type | description |
|---|---|---|
| component | any |
lightKind(componentType: string, component: any?) → string
The kind a light-role component names — `"point"`, `"spot"`, `"directional"`, `"ambient"`, `"distant"` or `"area"`. A type standing for exactly one kind spells it in its name (`SpotLight` is `"spot"`); a type covering several declares a `kind` field naming which one it is, the way `Light` does, and the field is read only from a component that declares one. So a world's own light component answers here on the same terms the engine's do. `nil` when the component names no kind.
| arg | type | description |
|---|---|---|
| componentType | string | The component's identity or its leaf name. |
| component | any? | The live component, read when its type names no single kind. |
examples
local kind = lighting.lightKind("SpotLight")lightsDefaultLayer(id: string) → boolean
Whether the entity `id` lights the default render layer — the layer the scene's sun and ambient term belong to, and the one an entity with no render layer of its own lands on. A light confined to other layers reaches the cameras on those layers through its own scoped record and holds neither singleton, so the scans below pass over it. Read guarded: an entity mid-teardown answers nothing, and nothing is on no layer.
| arg | type | description |
|---|---|---|
| id | string |
lightComponentsOf(e: any) → void
Every light-role component on an entity, each paired with the type that names its kind. An entity may carry several — a spot beside the sun's `Light`, a fill `DirectionalLight` on the same holder — so the question "which light on this entity" is answered by kind rather than by taking whichever one the entity reports first.
| arg | type | description |
|---|---|---|
| e | any |
lightOfKind(entityId: string, kind: string) → any
The light-role component of `kind` on one entity. An entity may carry several lights — a cone beside the sun's `Light`, a fill on the same holder — so this is how a caller reaches the one it means instead of whichever the entity reports first.
| arg | type | description |
|---|---|---|
| entityId | string | The entity to look on. |
| kind | string | The kind wanted, as `lightKind` names it. |
examples
local sun = lighting.lightOfKind(holder, "directional")
lightIsOn(light: any) → boolean
Whether a light component is switched on. A switched-off component's row is out of the renderer, so it holds neither of the scene's singleton field sets whatever kind it names. `enabled` is the engine's own property on every component, so every light answers it.
| arg | type | description |
|---|---|---|
| light | any |
scanForLightKind(kind: string, exclude: string?, onlyOn: boolean?) → string
| arg | type | description |
|---|---|---|
| kind | string | |
| exclude | string? | |
| onlyOn | boolean? |
scanForHolder(kind: string, exclude: string?) → string
The first entity that can HOLD the scene's `kind` field set: a switched-on light-role component of that kind, other than `exclude`.
| arg | type | description |
|---|---|---|
| kind | string | |
| exclude | string? |
resolveSunEntity( ) → string
A singleton field set resolves from the renderer's own answer first: the snapshot names the `Light` entity the frame's sun and the frame's ambient term are each carried by, so the write lands on the light the read reports. The snapshot is a frame behind, so a light spawned THIS frame is not in it yet — the component scan covers that gap, and it is what prices the resolve by the scene rather than by its lights.
resolveAmbientEntity( ) → string
upsertLightEntity(kind: string, resolvedId: string?, label: string, fields: { [string]: any }, spawnDefaults: { [string]: any }) → string
Patch the resolved light in place, or spawn a fresh entity when the scene has none: only the provided fields change, so a partial update never resets authored values. A patched component keeps its identity, its private bake state, and its live row — replacing it would hand the scene a different light that merely looks the same. A fresh spawn starts from `spawnDefaults`; `label` names only the NEW entity, it resolves nothing. `kind` picks WHICH light on the resolved entity is patched: an entity may carry a fill or a cone alongside the one holding a singleton field set, and the patch belongs to the one whose kind the caller named.
| arg | type | description |
|---|---|---|
| kind | string | |
| resolvedId | string? | |
| label | string | |
| fields | { [string]: any } | |
| spawnDefaults | { [string]: any } |
resolveSkyEntity( ) → string
The scene's sky entity — the one carrying a sky-role component (ProceduralSky / Skybox). Identity by component, same as the lights.
applySetup(opts: SetupOpts) → SetupResult
Apply a lighting setup in one call. All fields optional — only provided fields change (read-merge-write; unspecified fields keep their authored values). `sun` and `ambient` patch the scene's resolved sun / ambient light components, spawning a carrying entity when the scene has none. `sky` writes the sky entity's ProceduralSky component the same way — any of its fields, plus `enabled = false` to remove the sky component. `clearColor` is `{r, g, b}`. Returns the entity ids each provided section resolved to.
| arg | type | description |
|---|---|---|
| opts | SetupOpts |
setSun(opts: SunOpts) → string
Set the scene's sun, the same opts `applySetup`'s `sun` section takes. Read-merge-write: only the fields given change, so `{ intensity = 2 }` keeps the authored direction and colour. The light is resolved by component, and a scene carrying none is given one. castsShadows?: boolean }`.
| arg | type | description |
|---|---|---|
| opts | SunOpts | `{ direction?: {x,y,z}, color?: {r,g,b}, intensity?: number, |
examples
lighting.setSun({ intensity = 2.4, color = { 1, 0.95, 0.85 } })setAmbient(opts: AmbientOpts) → string
Set the scene's ambient term, the same opts `applySetup`'s `ambient` section takes. Read-merge-write: only the fields given change, so `{ intensity = 0.5 }` keeps the authored colour. The light is resolved by component, and a scene carrying none is given one.
| arg | type | description |
|---|---|---|
| opts | AmbientOpts | `{ color?: {r,g,b}, intensity?: number }`. |
examples
lighting.setAmbient({ intensity = 0.5 })lightScans( ) → number
How many times, since the module loaded, a light was reached by walking the scene's entities rather than through the record its own component published. A write that finds its light through the record leaves this count where it was, whatever the scene holds; one that walks the scene raises it by one per walk. Read it either side of a write to know which the write was.
examples
local before = lighting.lightScans(); lighting.setAmbient({ intensity = 0.5 }); assert(lighting.lightScans() == before)__registerSun(record: Sun?) → void
Publish the scene's sun — called by the `Light` component whenever the directional light it carries wakes, changes, or goes away. Nothing else should call this: the component is the one that knows.
| arg | type | description |
|---|---|---|
| record | Sun? | The sun's current values, or nil when the scene has no sun. |
__lightsDefaultLayer(id: string) → boolean
Whether the entity `id` lights the default render layer — the layer the scene's sun and ambient term belong to. A `Light` confined to other layers reaches the cameras on those layers through its scoped record and holds neither singleton, so it asks here before publishing itself as the sun.
| arg | type | description |
|---|---|---|
| id | string | The entity carrying the light. |
__resolveSun(exclude: string?) → void
Find a directional light in the scene and publish it as the sun. Called by a `Light` component that is giving the sun up — despawned, switched off, or no longer directional. A scene may carry several directional lights, so which one holds the sun is a question about the SCENE, not about the light that is leaving: the departing one cannot know who should take it. Only a switched-on light can take it, the renderer holding no row for one that is off. This scans, which is why it runs only on that handoff and never per frame. does so and is therefore skipped.
| arg | type | description |
|---|---|---|
| exclude | string? | The light handing the sun on, which is still in the scene as it |
sun( ) → Sun
The scene's sun: the values its directional `Light` currently carries. A table read when a component holds the sun — safe to call every frame.
examples
local s = lighting.sun(); print(s.intensity, s.direction[2])
directionals( ) →
Every directional light with its render layer scope: one record per light entity, each carrying `entityId`, `layerMask` (the render layer mask of the entity carrying it — cameras whose include mask intersects it are lit by it), `color`, `intensity`, `direction` and `castsShadows`. A light on an entity with no explicit render layer records the default layer.
examples
for _, d in lighting.directionals() do print(d.entityId, d.layerMask) end
ambients( ) →
Every ambient light with its render layer scope, on the same terms as `directionals`: one record per light entity, each carrying `entityId`, `layerMask`, `color` and `intensity`.
examples
for _, a in lighting.ambients() do print(a.entityId, a.layerMask) end
ambient( ) → Ambient
The scene's ambient term: the colour and intensity the frame's ambient light is shaded by, as the renderer resolved it. The term is one field set with one holder, so `entityId` names the ambient `Light` whose values it carries — a scene carrying several ambient lights reads here which of them the frame is shaded by, and the rest are authored and unread until one of them takes the term. ambient lights the scene.
examples
local a = lighting.ambient(); print(a.intensity, a.entityId)
setProbeVolume(key: string, volume: { [string]: any }) → void
Publish (or replace) the irradiance light-probe volume under `key`: `volume` is { boundsMin = {x,y,z}, boundsMax = {x,y,z}, res = {x,y,z}, sh = {...} } where `sh` is the baked SH L2 field as a flat float array (9 vec4 = 36 floats per probe, X-fastest then Y then Z). A scene carries one entry per baked volume (adaptive brick set); every standard-PBR fragment inside a volume's bounds takes its ambient term from the covering field(s) instead of the flat ambient light. `VolumeProbe.bake` publishes each freshly baked field through here automatically.
| arg | type | description |
|---|---|---|
| key | string | |
| volume | { [string]: any } |
lightRows( ) → void
Every punctual light in the scene as the renderer resolved it this frame: world-space `position`, `direction`, `color`, `intensity`, `radius`, the spot cone as `coneInnerCos`/`coneOuterCos`, and an area light's rect as `tangentU`/`tangentV` with `halfWidth`/`halfHeight` and `twoSided`. `kind` is "point", "spot", "area", or "distant", and `entityId` names the carrying entity when one exists. Every vector is a `{x, y, z}` array. A "distant" row is parallel light arriving from `direction` everywhere in the world — a second star, a moon, a fill from the far side. Its `direction`, `color` and `intensity` are what the shading reads, and its `radius` is 0: distance does not attenuate parallel light. The scene's sun is a single field rather than a row: read it with `lighting.sun()`. The transform hierarchy is already applied, so anything that has to shade the same lights the renderer does — a GI bake, a lighting inspector — reads world space here instead of re-deriving it from components. `shadowSlot` is where a shadow-casting point light was seated in the point-shadow cube pool, and `-1` when it was not: the pool holds `renderer.pointShadowBudget().slots` lights, and a caster past it renders lit with no shadow. `shadowLayer` and `shadowResolution` say the same for a spot or a rect: the spot-shadow atlas layer its depth map went into and the texel resolution it was given there, with `-1` for a caster the atlas had no tile left for.
removeProbeVolume(key: string) → void
Retract the irradiance light-probe volume published under `key` (volume removed / bake cleared). An empty key clears every published volume; fragments outside every remaining volume fall back to the flat ambient light term.
| arg | type | description |
|---|---|---|
| key | string |
LightRow = {Sun = {Ambient = {ScopedDirectional = {ScopedAmbient = {SunOpts = { direction: { number }?, color: { number }?, intensity: number?, castsShadows: boolean? }AmbientOpts = { color: { number }?, intensity: number? }ProceduralSkyOpts = { [string]: any }SetupOpts = {SetupResult = {Sub-parts
Everything contained inside this part. Assets are composite children (clickable cards). Files are leaf payloads. Expand any row to view its source.
Problems
Everything affecting this asset right now: its own problems, anything wrong inside it, and problems on its direct dependencies.
agent_score is exposed.+ quality × 0.35
+ performance × 0.25
± compat factor
Usability ratings
Did the part work as advertised when consumers tried to drop it in. Separate from upvotes: those are taste; this is "did it function".
Scoped to this part · feeds back into the world's score.