LightingService

Thin wrapper around the engine's directional sun + ambient term, plus an opt-in registry for which parts get that lighting applied and a LightSource data model you can plug into your own shaders.

local LightingService = import("LightingService")
local Primitives      = import("Primitives")
local CFrame, Color3, Vector = Primitives.CFrame, Primitives.Color3, Primitives.Vector

LightingService.SetSun(Vector.new(-0.3, -1.0, -0.4), Color3.fromRGB(255, 250, 235))
LightingService.SetAmbient(Color3.fromRGB(30, 35, 45))

-- Parts default to Lit = false. Add them explicitly to opt in.
LightingService:AddObject(somePart)

local lamp = LightingService.GenerateLightSource("PointLight", {
    CFrame     = CFrame.new(Vector.new(0, 4, 0), Vector.new(0, 0, 0)),
    Color      = Color3.fromRGB(255, 120, 80),
    Brightness = 4.0,
    Range      = 12.0,
})

Lighting model

The default 3D shader applies F.ambient + F.sun_color * lambert to any part whose Lit flag is on, and outputs flat colored pixels for everything else. The flag defaults to false on a newly-spawned Renderable.BasePart so that parts you intend to light yourself (custom Frag3D shader, light sources via SDATA, etc.) aren't fighting the built-in sun.

Toggle it with any of:

Shadow-cast hints (CastShadow, ReceiveShadow) are separately exposed as I.cast_shadow / I.receive_shadow in user shaders. The Lit flag is exposed as I.lit (u32 0/1) for shaders that want to branch on it.

Shadows. The shadow term in the default 3D shader is driven by knobs on GPUGPU.SetShadowsEnabled, SetShadowMapQuality, SetShadowDistance, SetShadowBias, SetShadowPCF. When enabled, the lambert term becomes a smoothstep at the light terminator (sharper, more "shadowy" look), receivers darken proportional to MapQuality, PCF widens the smoothstep band to simulate softness, and ShadowDistance fades the term back into half-Lambert past that camera distance.

Every GPU.SetShadow* knob is mirrored into the engine Frame uniform that every Frag3D shader already has, so you can read the same values directly inside your own shaders and build your own shadow logic on top:

F.shadow_enabledu320 or 1.
F.shadow_qualityu32Pixel size from SetShadowMapQuality.
F.shadow_distancef32World-space fade distance.
F.shadow_biasf32Surface-normal offset for shadow rays.
F.shadow_pcfu32Tap count, 1/3/5/9.
F.shadow_strengthf32Receiver darkening 0..1 (engine-derived).
F.shadow_softnessf32Terminator smoothstep band (engine-derived).

See GPU > Reading shadow state from your own shaders for the full table, a worked soft-shadow example, and the caster-buffer layout that ships with the floor / per-part example shaders.

LightingService API

LightingService.SetSun(direction, color?)

Set the directional sun. direction points FROM the sun TOWARD the scene. Optional color tints the sun; pass nil to keep the current color. The direction doesn't need to be normalized.

LightingService.SetSunDirection(direction)

Update the sun direction only; color stays unchanged.

LightingService.SetSunColor(color)

Tint the sun. Surfaced as F.sun_color in every 3D shader.

LightingService.SetAmbient(color)

Ambient term added to every lit pixel before the sun contribution. Default Color3.new(0.25, 0.25, 0.25). Surfaced as F.ambient in 3D shaders.

LightingService.GetSunDirection() -> Vector

Current sun direction.

LightingService.GetSunColor() -> Color3

Current sun color.

LightingService.GetAmbient() -> Color3

Current ambient term.

LightingService.Get() -> { SunDirection, SunColor, Ambient }

Snapshot all three at once. Handy for debug HUDs and save / load.

LightingService:AddObject(part)

Mark a BasePart as participating in engine-managed lighting. Equivalent to part.Lit = true.

LightingService:RemoveObject(part)

Remove a part from engine-managed lighting (it goes back to flat-colored output through the default shader). Equivalent to part.Lit = false.

LightingService:IsObjectManaged(part) -> boolean

Whether the part's Lit flag is currently on.

LightingService.GenerateLightSource(kind, opts?) -> LightSource

Spawn a new LightSource and add it to the active registry. kind is the string "PointLight" or "Spotlight". Every field in opts is optional — the defaults give you a white point light at the origin with brightness 1 and range 16. Spotlight-only fields (ConeInner, ConeOuter) are ignored on a PointLight.

LightingService.GetActiveLights() -> { LightSource }

Every alive LightSource as a Lua array.

LightingService.GetLightCount() -> number

How many LightSources are currently alive. Cheap.

LightingService.UploadLights(buffer) -> number

Pack the current set of LightSources into a GPU storage buffer (allocated via GPU.NewBuffer). After uploading, bind the same buffer as the active SDATA via GPU.SetBuffer(buffer) and your 3D shaders can read the lights through SDATA. Returns the number of lights written. Errors if the buffer is too small.

Buffer layout (floats):

[0]    bitcast<u32> of light_count
[1..3] padding
[4 + i*16 .. 4 + (i+1)*16 - 1]  16 floats per light:
  +0..2   position.xyz       +3   kind (0 = point, 1 = spot)
  +4..6   direction.xyz      +7   brightness
  +8..10  color.rgb          +11  range
  +12     cos(cone_inner)    +13  cos(cone_outer)
  +14     falloff            +15  cast_shadow (0/1)

Allocate at least 4 + maxLights * 16 floats.

Skybox & post-processing

Moved here from GUI in 1.2.1. Same signatures and semantics as before — the only thing that changed is the namespace. Both functions return a SceneShader handle you keep around to drive runtime params or clear the slot.

LightingService.SetSkybox(asset) -> SceneShader

Run a fragment shader on a fullscreen quad before the 3D scene draws. Replaces the flat clear colour with whatever the shader produces — gradient sky, procedural stars, animated clouds, screenspace ray-march, anything. The 3D scene draws on top of it, so opaque parts naturally occlude it. Same FragmentAsset shape as a 2D primitive shader, with U.resolution, U.time, and p(idx) available.

LightingService.ClearSkybox()

Remove the active skybox; reverts to the flat clear colour.

LightingService.SetPostEffect(asset) -> SceneShader

Install a fragment shader that runs ONCE per frame as the final pass. Every cube, model, GUI primitive, skybox, and shader is composited into a single 2D image first, then your post shader transforms it and writes the result to the swapchain. IMG is bound to the composited scene — sample via textureSample(IMG, IMG_SAMP, in.uv). Used for color grading, vignette, CRT, bloom, damage flashes, etc. A no-op shader is literally:

return textureSample(IMG, IMG_SAMP, in.uv);
LightingService.ClearPostEffect()

Remove the active post effect; the rendered scene goes straight to the swapchain again.

SceneShader

Returned by SetSkybox and SetPostEffect. Lifetime ties to the slot — let it drop or call :Destroy() to remove the shader.

scene:SetData(name, value)Set a @ruzit param slot.
scene:GetData(name)Read it back.
scene:Destroy()Clear the slot.

Migration from 1.2.0

-- old (removed):
-- GUI.SetSkybox(asset)
-- GUI.SetPostEffect(asset)

-- new:
local LightingService = import("LightingService")
local sky   = LightingService.SetSkybox(Asset.GetAsset("Fragment", "sky.frag"))
local grade = LightingService.SetPostEffect(Asset.GetAsset("Fragment", "damage.frag"))

LightSource

Returned by LightingService.GenerateLightSource. Edit the fields freely; the next UploadLights call picks up the changes. Spotlight-only fields error when set on a PointLight.

.Kindstringread-only

"PointLight" or "Spotlight".

.CFrameCFrame

World pose. For a Spotlight, the rotation determines the cone direction (forward = -Z, same convention as Renderable.Camera).

.ColorColor3

RGB color. Multiplied by Brightness at the pixel level.

.Brightnessnumber

Intensity multiplier. 0 disables the light; 1 is "bright office bulb at 1m" in the demo shader.

.Rangenumber

Maximum reach in world units. Pixels beyond Range receive zero contribution in the demo falloff.

.Falloffnumber

Distance attenuation exponent for the demo shader's (1 - d / Range)^Falloff term. 2.0 is roughly physical inverse-square; lower values are softer.

.CastShadowboolean

Hint flag for shadow casting. Packed into the buffer as 0/1 for shaders to read. The default pipeline doesn't act on it yet — custom shaders can.

.ConeInnernumberSpotlight only

Inner cone half-angle in radians. Inside this cone the light is at full intensity. cos(ConeInner) is what gets packed into the buffer.

.ConeOuternumberSpotlight only

Outer cone half-angle in radians. Past this the light is zero. The smoothstep between Inner and Outer gives soft cone edges.

.Alivebooleanread-only

False once :Destroy() has been called.

:Destroy()method

Free the light. Removes it from the active registry so the next UploadLights won't include it.

:AttachShader(asset)method

Attach a Frag3D shader asset to this light. The shader id is stored on the light and exposed to user code that wants per-light shader variants. The default pipeline doesn't dispatch it — this is data you can read from your own shader or Lua side.

:DetachShader()method

Drop the attached shader.

:SetShaderParam(slot, value)method

Write one of the light's 16 shader-param floats. Slot is clamped to 0..15. Read alongside the light data from custom shaders.

:Direction()-> Vectormethod

World-space forward derived from CFrame.Rotation. For a Spotlight this is the cone direction.

Example: custom shader reading the lights

See examples/custom_lighting.luau and examples/lighting_shader.wgsl in the repo for a working demo. The short version: allocate a buffer, upload each frame, bind as SDATA, write a Frag3D shader that decodes the layout and does Lambert + falloff + spot-cone per light.