Luduvo Contents
Theme
On this page

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:

Geometry and shadowsShading and post1 Light collection512 point and 512 spot2 Early cullDrops offscreen geometry3 Shadow cullSelects the shadow casters4 Early depth prepassDepth of visible surfaces5 Hi-Z buildDepth summary for culling6 Late cullRetests draws against depth7 Late prepassDepth for late draws8 Sun shadowsPer-pixel sun visibility9 Spot and point shadowsFor shadow-casting lights10 Opaque shadingWrites linear HDR radiance11 SkyAtmosphere dome or color12 MSAA resolveSamples into one target13 GTAOOccludes ambient light only14 Aerial perspectiveFogs by sun transmittance15 Transparency OITWeighted blend after GTAO16 Post-processingBloom, tone map and SMAA

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.
  • Light component: 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.

Luau
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
PropertyRangeMeaning
Sun.Azimuth0-360 degCompass angle of the sun. Default 220.95.
Sun.Elevation-90 to 90 degHeight of the sun over the horizon. Default 40.28.
Sun.TintColor3Color of the direct sun term. Default white.
Sun.Intensity0-20Multiplier of 120,000 lux; 0 turns the sun off. Default 1.
Ambient.TintColor3Flat ambient color, applied when Source is Color.
Ambient.Intensity0-3Strength of the ambient term.
Ambient.Source0 Follow Background, 1 Color, 2 DisabledWhere ambient light comes from; Disabled zeroes it.
Sky.Mode0 Atmosphere, 1 ColorSimulated sky dome, or a solid clear color.
Sky.ColorColor3Clear color the background uses when Mode is Color.
Sky.Contribution0-1Share of the sky spherical harmonics feeding ambient.
Stars.Luminance0-20Brightness of the star field behind the atmosphere; 0 hides the stars.
Flare.Intensity0-10Strength of the lens flare; 0 disables the flare passes.
Flare.Scale0.1-5Size of the flare.
Flare.TintColor3Color of the flare.
Flare.Ghost0-10Scale of the ghost reflections.
Exposure.MinEV-6 to 20 EVLower bound of auto exposure. Default 0.
Exposure.MaxEV-6 to 20 EVUpper bound of auto exposure. Default 16.
Shadows.Mode0 None, 1 HardPixel-exact hard sun shadows, or none.
Shadows.Fill0-1Direct 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: 0 for None, 1 for 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 as lerp(Fill, 1.0, visibility) on the sun term only; ambient light is unaffected. Setting Fill to 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:

Luau
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 = true
  • Light: Point light component. Exposes LightColor (Color3), Intensity (0-1000, default 75), and Range (0-1000 studs, default 100).
  • SpotCone: Converts a Light on the same entity into a spotlight via cone half-angles in radians: InnerAngle (full intensity inside the cone) and OuterAngle (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:

Text
f_r = k_d * f_diffuse + k_s * f_specular

where:

  • f_diffuse = albedo / pi represents Lambertian diffuse reflection.
  • f_specular = (D * G * F) / (4 * (N . L) * (N . V)) represents specular microfacet reflection.
  • k_d and k_s are 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:

  1. 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):

    Text
    D(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.

  2. Geometric Shadowing-Masking Function G (Smith model with Schlick-GGX): Accounts for microfacets shadowing incoming light or occluding the viewer’s line of sight:

    Text
    G(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 / 8
  3. Fresnel Reflectance F (Fresnel-Schlick approximation): Calculates the ratio of light reflected versus transmitted as a function of viewing angle:

    Text
    F(H, V, F0) = F0 + (1 - F0) * (1 - max(H . V, 0))^5

    In the metallic-roughness workflow, interpolate the base reflectivity at normal incidence F0 between non-metal dielectrics and pure conductors:

    Text
    F0 = 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 with albedo.

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:

ChannelPropertyMeaning
Red (R)OcclusionAmbient Occlusion (AO). Pre-baked cavity shadowing scaling ambient diffuse lighting.
Green (G)RoughnessSurface microfacet roughness (clamped to >= 0.04).
Blue (B)MetallicMetalness 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:

Text
radiance_emissive = 4.0 * albedo

Because 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.

Luau
part.Material = "Metal"part.Color = Vector3.new(0.9, 0.7, 0.2)part.Transparency = 0.5

Color (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 slotContent
AlbedoBase color RGB, alpha A
NormalTangent-space normal map
RoughnessRoughness (green channel of MetallicRoughness)
MetallicMetallic (blue channel of MetallicRoughness)
OcclusionAmbient occlusion red channel
EmissiveEmissive color texture at local-light scale
UnderlaySecond 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:

TorsoRig root, the joint parentHeadMotor joint to the torsoLeftArmMotor joint to the torsoRightArmMotor joint to the torsoLeftLegMotor joint to the torsoRightLegMotor joint to the torso

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 transform M_child by composing these local frames with the dynamic motor rotation R(theta):
Text
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:

Face decalAsset id on the rootHead AlbedoHead surface base colorShirtAsset id on the rootTorso AlbedoTorso surface base colorArm AlbedoLeft and right armsPantsAsset id on the rootLeg AlbedoLeft and right legsTorso UnderlayBeneath shirt cutouts

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:

Linear depth pyramidDownsampled view depthHorizon searchSweeps neighboring geometryAnalytical integrationVisibility cone between horizon anglesSpatial denoisingBilateral filter keeping depth edges

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 detectionColor and luminance differencesPattern and area weightsQuality sets the search depthNeighborhood blendingBlends by coverage weights

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.

Luau
local screen = game.World.Screenlocal cam = game.World.Cameraif cam then	cam.CameraLens.fovY = math.rad(90)	cam.CameraOrbit.distance = 8end

Component 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.