Lighting and Materials
The engine shades every pixel in a single forward pass. That pass samples the surface material, evaluates direct sunlight and ambient light, adds the local lights that reach the pixel, and writes linear high dynamic range (HDR) radiance. Post-processing then meters that radiance, tone maps it, and anti-aliases the image. The engine renders at whatever refresh rate the display allows.
How the renderer does this inside: Rendering.
Clustered forward rendering and light binning#
A naive forward shader tests every fragment against every light in the scene, so a scene with
hundreds of lights costs O(fragments * lights). The engine instead splits the view into a
grid of small volumes and records, per volume, which lights reach into it; a pixel then
evaluates only the lights recorded for the volume it sits in. Hundreds of active local lights
stay affordable this way, and what a light costs follows its Range rather than its Intensity.
Each frame collects at most 512 point lights and 512 spot lights; the engine drops any further light in collection order. Of the lights that survive collection, at most 4 point lights and 8 spot lights cast shadows, and the rest render unshadowed.
Every frame runs these passes in order:
The numbering is the execution order, and it is strict: shading starts only after the sun, spot, and point shadows are written, transparency resolves after the opaque image, and post-processing runs last.
game.Profiler.Frame().render returns cumulative GPU duration across all passes in milliseconds.
Physical units#
The shaders work in physical units. Sun illuminance at the top of the atmosphere is 120,000 lux, and every intensity a script sets is a dimensionless multiplier on top of that baseline:
Lighting.Sun: Multiplier of 120,000 lux, evaluated before atmospheric transmittance.Lighting.Ambient: Multiplier of a 15,000 lux sky, divided by pi.Lightcomponent: Multiplier of the sun’s authored scale, divided by pi and by squared distance in studs.- Emissive textures and
Neon: Uses the same scale as a local light.
Because all light sources share the sun’s scale, a local light with Intensity = 1 positioned at 1 stud produces the same irradiance as direct sunlight on a perpendicular surface.
With default Intensity = 75 and Range = 100, attenuation evaluates to 0.6075 of full sun at 10
studs and 0.0075 at 50 studs. Because the auto-exposure system dynamically absorbs the absolute illumination level,
the relative ratio between light sources determines the final image rather than the raw values themselves.
Sun and atmosphere#
Configure the celestial environment through game.Lighting, which exposes seven property groups.
Each group acts as a world singleton: reading a group returns its current value (or the default when
the place never set it), while writing creates the corresponding row. Set these
properties from a server script so every connected client shares the same sky; writing from a client
script alters only that local client’s view.
local L = game.LightingL.Sun.Azimuth = 220L.Sun.Elevation = 40L.Sun.Tint = Vector3.new(1, 0.95, 0.9)L.Sun.Intensity = 1L.Sky.Mode = 0L.Sky.Contribution = 1L.Ambient.Source = 0L.Ambient.Intensity = 1L.Exposure.MinEV = 0L.Exposure.MaxEV = 16| Property | Range | Meaning |
|---|---|---|
Sun.Azimuth | 0-360 deg | Compass angle of the sun. Default 220.95. |
Sun.Elevation | -90 to 90 deg | Height of the sun over the horizon. Default 40.28. |
Sun.Tint | Color3 | Color of the direct sun term. Default white. |
Sun.Intensity | 0-20 | Multiplier of 120,000 lux; 0 turns the sun off. Default 1. |
Ambient.Tint | Color3 | Flat ambient color, applied when Source is Color. |
Ambient.Intensity | 0-3 | Strength of the ambient term. |
Ambient.Source | 0 Follow Background, 1 Color, 2 Disabled | Where ambient light comes from; Disabled zeroes it. |
Sky.Mode | 0 Atmosphere, 1 Color | Simulated sky dome, or a solid clear color. |
Sky.Color | Color3 | Clear color the background uses when Mode is Color. |
Sky.Contribution | 0-1 | Share of the sky spherical harmonics feeding ambient. |
Stars.Luminance | 0-20 | Brightness of the star field behind the atmosphere; 0 hides the stars. |
Flare.Intensity | 0-10 | Strength of the lens flare; 0 disables the flare passes. |
Flare.Scale | 0.1-5 | Size of the flare. |
Flare.Tint | Color3 | Color of the flare. |
Flare.Ghost | 0-10 | Scale of the ghost reflections. |
Exposure.MinEV | -6 to 20 EV | Lower bound of auto exposure. Default 0. |
Exposure.MaxEV | -6 to 20 EV | Upper bound of auto exposure. Default 16. |
Shadows.Mode | 0 None, 1 Hard | Pixel-exact hard sun shadows, or none. |
Shadows.Fill | 0-1 | Direct sunlight surviving inside shadow. Default 100/255. |
Setting Sky.Mode = 0 simulates a physically based atmosphere with Rayleigh, Mie, and ozone
scattering. Atmospheric transmittance attenuates and reddens direct sunlight at low solar
elevations, and aerial perspective fogs distant geometry by the same model. When the sun drops
below the horizon, sun transmittance falls to zero and the engine disables shadow evaluation.
Setting Sky.Mode = 1 skips the sky pass and aerial perspective: the
background clears directly to Sky.Color, and ambient light can no longer
follow the background (so Ambient.Source = 0 behaves as a flat white sky).
Ambient illumination blends two terms weighted by Contribution:
the flat term Ambient.Tint * Ambient.Intensity and the dynamic sky term, which the
atmosphere writes every frame.
With Source = 0 and Contribution = 1, ambient lighting comes entirely from the
sky; with Contribution = 0 or Source = 1, it comes entirely from the flat
color.
Auto exposure#
To match how the eye adapts to changing light, the engine measures the average luminance of the
scene each frame, converts it to an exposure value with EV = log2(L * 100 / 12.5), clamps that
value to [MinEV, MaxEV], and moves the previous frame’s exposure toward it at 3 stops per
second when brightening and 1 stop per second when darkening. Scene radiance is then divided by 1.2 * 2^EV. Use MinEV as the exposure floor to prevent dark interiors from over-brightening,
and MaxEV as the exposure ceiling to prevent bright exteriors from over-dimming. The engine
uses an ACES filmic tone mapping curve.
Sun shadows (Razor)#
The sun casts shadows with Razor, a technique that tests each visible pixel against the exact shape of the geometry standing between it and the sun, rather than against a shadow map rendered from the sun’s point of view. The answer is per pixel: a pixel is either lit by the sun or it is not.
Two artifacts of shadow maps are therefore absent. Shadow edges stay exact however close the camera comes instead of breaking into stair steps, and a surface never shadows itself in speckled bands (“shadow acne”) or floats above its own shadow (“peter-panning”). There is no depth bias to tune.
Limits#
Sun shadows cover the first 500 studs of view distance. Geometry farther out than that is lit by the sun without a shadow test, so a shadow does not appear until its caster comes inside that distance.
To keep frame times predictable, the engine also caps how much work one shadow lookup may do. Where a scene exceeds that cap, the affected pixels are left unshadowed and the client logs a warning, so a scene that overflows is never silently wrong.
Configure sun shadows through game.Lighting.Shadows:
Mode:0for None,1for Hard. (The engine renders either hard pixel-exact shadows or none; there is no filtered soft shadow mode).Fill: Fraction of direct sunlight surviving inside shadowed regions (0 to 1, default 100/255). The shader applies this aslerp(Fill, 1.0, visibility)on the sun term only; ambient light is unaffected. SettingFillto 0 gives a shadow lit by ambient alone.ShadowOff: Tag component placed on any entity to remove it from all shadow passes: sun, spot, and point. The entity still receives shadows cast by other geometry.
Local light sources#
Place local light sources in the world by attaching a Light component (point light),
optionally augmented with SpotCone (converting the light to a spotlight via
cone half-angles in radians) and LightShadowOff (a tag component suppressing shadow casting).
The light’s position and forward axis follow its entity transform:
local lamp = game.Prefabs.Spawn("Part")lamp.Position = Vector3.new(0, 8, 0)lamp:AddComponent("Light")lamp.LightColor = Vector3.new(1, 0.8, 0.6)lamp.Intensity = 120lamp.Range = 40local torch = game.Prefabs.Spawn("Part")torch:AddComponent("Light")torch:AddComponent("SpotCone")torch.InnerAngle = 0.3torch.OuterAngle = 0.5torch.LightShadowOff = trueLight: Point light component. ExposesLightColor(Color3),Intensity(0-1000, default 75), andRange(0-1000 studs, default 100).SpotCone: Converts aLighton the same entity into a spotlight via cone half-angles in radians:InnerAngle(full intensity inside the cone) andOuterAngle(fades to zero), both 0 to 1.57 rad (defaults 0.35 and 0.5).LightShadowOff: Tag component suppressing shadow casting for the light. When absent, the light casts shadows.
Calculate light attenuation using the formula (1 - d / Range)^2 / max(d^2, 1):
this gives an inverse-square falloff with a smooth window to zero at Range, clamped inside
the first stud so a surface touching the emitter does not blow out. A light contributes
nothing past Range, and Range is also what the engine uses to decide which pixels the light
can reach. Because of this, a large range makes a light expensive for the GPU, not a high
intensity. A shadow-casting point light casts in every direction out to Range, and a
shadow-casting spot light casts within its cone.
The engine stores a light’s color in sRGB and converts it to linear space when the
light is collected, just like Color on a part.
Materials and Physically Based Rendering (PBR)#
Surfaces are shaded using a Physically Based Rendering (PBR) reflectance model based on the Cook-Torrance microfacet formulation:
f_r = k_d * f_diffuse + k_s * f_specularwhere:
f_diffuse = albedo / pirepresents Lambertian diffuse reflection.f_specular = (D * G * F) / (4 * (N . L) * (N . V))represents specular microfacet reflection.k_dandk_sare the diffuse and specular energy fractions that enforce conservation of energy (k_d = (1 - k_s) * (1 - metallic)).
As in standard modern PBR, the specular term combines three essential microfacet approximations:
Normal Distribution Function D (Trowbridge-Reitz GGX): Models the statistical orientation of surface microfacets relative to the halfway vector
H = (L + V) / length(L + V):TextD(N, H, alpha) = alpha^2 / (pi * ((N . H)^2 * (alpha^2 - 1) + 1)^2)where
alpha = roughness^2. Lower roughness values concentrate microfacets tightly along the macroscopic surface normal N, creating sharp, intense specular highlights. Clamp roughness to a minimum of 0.04 to prevent division-by-zero singularities.Geometric Shadowing-Masking Function G (Smith model with Schlick-GGX): Accounts for microfacets shadowing incoming light or occluding the viewer’s line of sight:
TextG(N, V, L, k) = G1(N, V, k) * G1(N, L, k)G1(N, v, k) = (N . v) / ((N . v) * (1 - k) + k), where k = (roughness + 1)^2 / 8Fresnel Reflectance F (Fresnel-Schlick approximation): Calculates the ratio of light reflected versus transmitted as a function of viewing angle:
TextF(H, V, F0) = F0 + (1 - F0) * (1 - max(H . V, 0))^5In the metallic-roughness workflow, interpolate the base reflectivity at normal incidence
F0between non-metal dielectrics and pure conductors:TextF0 = lerp(0.04, albedo, metallic)Dielectric surfaces (plastics, wood, concrete) reflect 4% of incident light at normal incidence and produce diffuse coloring from
albedo. Conductive metals have zero diffuse reflection (k_d = 0) and tint their specular reflection directly withalbedo.
A part’s Material is a SurfaceMaterial asset reference written by the server.
Built-in SurfaceMaterial asset references include Plastic, Metal, Wood, Concrete, Glass,
and Neon (with Glass selecting the Transparent pipeline class, and SmoothPlastic, Brick,
Grass, Ice, Sand, Aluminum, Rust, WoodPlanks, Marble, Slate, Granite, Pebble, Cobblestone,
DiamondPlate, and Fabric also available). game.Materials.DefineMaterial registers custom materials on the server and returns a 64-bit asset ID; each
spec declares a pipeline class (Opaque, Cutout, Transparent, Water, Decal) and
a surface model (UV, Triplanar, Stud). An unassigned entity defaults to Plastic.
ORM texture packing#
Material properties pack into a single 3-channel (RGB) ORM texture:
| Channel | Property | Meaning |
|---|---|---|
| Red (R) | Occlusion | Ambient Occlusion (AO). Pre-baked cavity shadowing scaling ambient diffuse lighting. |
| Green (G) | Roughness | Surface microfacet roughness (clamped to >= 0.04). |
| Blue (B) | Metallic | Metalness mask (0.0 for dielectrics, 1.0 for pure metals). |
A compiled material asset bundles this ORM texture alongside a base color
(Albedo) texture, a tangent-space normal map, scalar multipliers
(RoughnessMult, MetallicMult, NormalStrength, TileSize), and an
optional emissive texture with tint and intensity.
Neon materials and bloom#
The Neon material flag turns a surface into a light emitter. The surface is shaded normally,
and then four times its albedo is added on top as emitted light:
radiance_emissive = 4.0 * albedoBecause lighting is evaluated in pre-exposed HDR units where 1.0 represents
the luminance of full direct sunlight, adding 4.0 * albedo forces fragment radiance well past the 1.0 bloom threshold. During
post-processing, the bloom extraction pass detects these over-bright pixels
and scatters their energy across neighboring screen pixels, producing Neon’s
characteristic glowing halo.
part.Material = "Metal"part.Color = Vector3.new(0.9, 0.7, 0.2)part.Transparency = 0.5Color (backed on the wire by the BrickColor component) provides the
Color/BrickColor tint, a Color3 in sRGB. It multiplies the albedo when
the material has no base color texture, when TintVertex is set, or on
clothing decals.
Transparency (0 opaque to 1 invisible) controls surface opacity: Transparency = 0 is fully opaque, while Transparency = 1 is completely
invisible and culled from rendering. Any value between 0 and 1 diverts the surface from
the opaque pass to the order-independent transparency (OIT) pass - a
weighted-blended accumulation resolved after GTAO so transparent surfaces
never sort-pop.
SurfaceAppearance PBR texture channels#
Override PBR textures per mesh entity using a SurfaceAppearance component.
Each channel takes an asset ID (a decimal string; passing 0 keeps the underlying material map):
| Channel slot | Content |
|---|---|
Albedo | Base color RGB, alpha A |
Normal | Tangent-space normal map |
Roughness | Roughness (green channel of MetallicRoughness) |
Metallic | Metallic (blue channel of MetallicRoughness) |
Occlusion | Ambient occlusion red channel |
Emissive | Emissive color texture at local-light scale |
Underlay | Second albedo composited beneath Albedo |
Imported glTF meshes arrive with their texture channels bound to these SurfaceAppearance slots. The component replicates across the network automatically.
Meshes and characters#
The Mesh component draws an external mesh asset in place of a primitive part: Mesh holds the asset ID, while the collider remains defined by Collider.
Construct characters from rig BodyPart and LimbAttachment motor
joints. A character entity has a Character tag and child entities each marked
by a BodyPart enum component:
The limbs connect through LimbAttachment components holding motor
joints. In hierarchical skeletal rigs, each joint connects a
parent body part (such as the Torso) to a child limb (such as an Arm or Leg)
using two local coordinate reference frames:
Joint(the parent attachment frame): a transform giving the joint socket’s position and orientation relative to the parent part’s center.Pivot(the child pivot frame): a transform giving the pivot point’s position and orientation relative to the child limb’s origin. During animation evaluation, the solver computes the child limb’s final world transformM_childby composing these local frames with the dynamic motor rotationR(theta):
M_child = M_parent * Joint * R(theta) * Pivot^(-1)Rig motor joints are initialized from the rig asset upon attachment.
Drive character visual layers using CharacterAppearance on the root,
specifying Face decal, Shirt, and Pants textures as asset IDs. When
materializing the character, the server assigns these textures to the
body parts’ SurfaceAppearance channels:
The face decal texture becomes the Head Albedo, the shirt texture becomes the Albedo of the Torso and both arms, and the pants texture becomes the Albedo of both legs as well as the Torso Underlay.
Compositing Pants as the Torso’s Underlay maps the pants texture beneath
shirt cutouts or transparent regions, while untextured skin takes the part’s Color tint.
When all body parts use the platform body mesh, the client combines them
into a single skinned draw call; custom body meshes fall back to separate part
draws while keeping rig motor joints active.
Post-processing and camera#
The post-processing pipeline executes after the opaque geometry, sky, and transparency passes have resolved linear HDR radiance.
Ground-Truth Ambient Occlusion (GTAO)#
GTAO computes ambient shadowing from the depth buffer:
The linear depth pyramid step downsamples the view-space depth buffer so the occlusion shader can sample wide neighborhoods cheaply. The horizon search step then sweeps, for each pixel, along screen-space directions to find the maximum horizon elevation angle subtended by neighboring geometry. Analytical integration integrates the unoccluded hemispherical visibility cone between the positive and negative horizon angles projected against the surface normal. Spatial denoising finally runs a bilateral filter that cleans sampling noise while preserving sharp geometric depth edges. The occlusion factor it produces scales the ambient irradiance term only; direct sunlight and local lights stay unoccluded.
Subpixel Morphological Anti-Aliasing (SMAA)#
SMAA runs on the tone-mapped low dynamic range (LDR) color buffer to smooth geometric silhouette edges without the blur of FXAA and at a lower cost than multi-sample anti-aliasing (MSAA):
Edge detection computes color and luminance differences across adjacent pixels to identify high-contrast geometric silhouettes. Pattern recognition and area weighting traces along the detected edge lines to classify morphological patterns (such as diagonal steps, corners, and T-junctions); the SMAA quality setting decides how far it searches along an edge, up to 4, 8, 16, or 32 steps for Low, Medium, High, and Ultra, and a precomputed area texture supplies the subpixel coverage weights. Neighborhood blending then blends adjacent pixel colors using those coverage weights, smoothing silhouette edges while leaving text and 2D UI elements unaffected.
Bloom and tone mapping#
The bloom pass extracts bright highlights by isolating fragments whose luminance exceeds 1.0, blurring them, and adding the blurred radiance back into the scene. ACES filmic tone mapping then maps high dynamic range radiance to the displayable image.
Camera and Reverse-Z perspective projection#
In the engine, the camera is an ordinary ECS entity rather than a singleton service. The output
screen references the active camera via a ViewSource relation. In Lua, game.World.Screen.ViewSource assigns the camera view source, and game.World.Camera returns the active view source entity.
Configure camera projection through the CameraLens component, which
defines the vertical field of view (fovY in radians) and near/far clip
distances (nearZ, farZ).
Numerical depth precision: Why Reverse-Z?
In traditional perspective projection, the near clipping plane maps to
z = 0.0 and the far clipping plane to z = 1.0. But because the perspective
division divides clip coordinates by linear view depth (w = -z_view),
normalized device coordinate (NDC) depth is proportional to 1 / z_view.
This non-linear reciprocal mapping spends over half of the available depth
precision on the first 2% of the view distance. Farther out, distinct surfaces
round to identical depth values and flicker against each other, an artifact
known as z-fighting.
The engine instead uses a Reverse-Z projection, where the near plane maps to z = 1.0 and the far plane to z = 0.0. Floating-point precision is densest near 0.0, which offsets the 1/z compression of the perspective divide: relative depth precision stays nearly constant across the view distance, and distant geometry stops fighting.
Camera rigs attach as components to the camera entity:
CameraOrbit: Default third-person orbit camera around a target subject.CameraFly: Freecam mode providing yaw, pitch, and zoom control.
When steered by user input (InputSteer), mouse deltas rotate freecam
yaw and pitch in radians (0.001 * sensitivity), while mouse wheel input
provides zoom and forward movement impulses.
local screen = game.World.Screenlocal cam = game.World.Cameraif cam then cam.CameraLens.fovY = math.rad(90) cam.CameraOrbit.distance = 8endComponent fields are accessed through the component name. Fields written
by the rig each frame (smoothDistance, smoothSideOffset, fade) are
read-only. The rig overwrites Position and Rotation on an orbit or fly camera
every frame; placing a camera manually by script requires attaching CameraLens only and setting the transform directly.