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:
LightingService:AddObject(part)/:RemoveObject(part)part.Lit = true/part.Lit = false- Or just attach your own
Frag3Dshader to that part — the attached shader replaces the default fragment stage entirely, so the flag is moot there.
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 GPU —
GPU.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_enabled | u32 | 0 or 1. |
F.shadow_quality | u32 | Pixel size from SetShadowMapQuality. |
F.shadow_distance | f32 | World-space fade distance. |
F.shadow_bias | f32 | Surface-normal offset for shadow rays. |
F.shadow_pcf | u32 | Tap count, 1/3/5/9. |
F.shadow_strength | f32 | Receiver darkening 0..1 (engine-derived). |
F.shadow_softness | f32 | Terminator 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
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.
Update the sun direction only; color stays unchanged.
Tint the sun. Surfaced as F.sun_color in every 3D
shader.
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.
Current sun direction.
Current sun color.
Current ambient term.
Snapshot all three at once. Handy for debug HUDs and save / load.
Mark a BasePart as participating in engine-managed
lighting. Equivalent to part.Lit = true.
Remove a part from engine-managed lighting (it goes back to
flat-colored output through the default shader). Equivalent
to part.Lit = false.
Whether the part's Lit flag is currently on.
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.
Every alive LightSource as a Lua array.
How many LightSources are currently alive. Cheap.
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.
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.
Remove the active skybox; reverts to the flat clear colour.
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);
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.
"PointLight" or "Spotlight".
World pose. For a Spotlight, the rotation determines the cone
direction (forward = -Z, same convention as
Renderable.Camera).
RGB color. Multiplied by Brightness at the pixel
level.
Intensity multiplier. 0 disables the light;
1 is "bright office bulb at 1m" in the demo
shader.
Maximum reach in world units. Pixels beyond Range receive zero contribution in the demo falloff.
Distance attenuation exponent for the demo shader's
(1 - d / Range)^Falloff term. 2.0 is
roughly physical inverse-square; lower values are softer.
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.
Inner cone half-angle in radians. Inside this cone the light is
at full intensity. cos(ConeInner) is what gets
packed into the buffer.
Outer cone half-angle in radians. Past this the light is zero. The smoothstep between Inner and Outer gives soft cone edges.
False once :Destroy() has been called.
Free the light. Removes it from the active registry so the
next UploadLights won't include it.
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.
Drop the attached shader.
Write one of the light's 16 shader-param floats. Slot is
clamped to 0..15. Read alongside the light data
from custom shaders.
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.