Lighting & Shadows
PolyCSS shades each polygon with a Lambert model that matches three.js’s MeshLambertMaterial. A scene takes one directional light, one ambient light, and any number of point lights, set on <poly-scene> / PolyScene / createPolyScene(). Shading happens either once on the CPU (baked) or live in CSS calc() (dynamic) — see Lighting modes.
The light types are documented in the Core Types reference; this guide is the conceptual tour.
The light sources
Section titled “The light sources”Directional light
Section titled “Directional light”One infinitely-distant light with a single direction, color, and intensity — like the sun. Every surface gets color · intensity · max(0, n · L̂).
Drag light ° / light ↑ to move the sun and watch the lit side — and the cast shadow on the ground — follow; intensity and ambient control brightness and fill.
import { createPolyScene, createPolyCamera } from "@layoutit/polycss";
const scene = createPolyScene(host, { camera: createPolyCamera({ rotX: 60, rotY: 30 }), directionalLight: { direction: [0.5, -0.6, 0.6], color: "#ffffff", intensity: 1 }, ambientLight: { intensity: 0.3 },});import { PolyCamera, PolyScene } from "@layoutit/polycss-react";
<PolyCamera rotX={60} rotY={30}> <PolyScene directionalLight={{ direction: [0.5, -0.6, 0.6], color: "#ffffff", intensity: 1 }} ambientLight={{ intensity: 0.3 }} > {/* meshes */} </PolyScene></PolyCamera><template> <PolyCamera :rot-x="60" :rot-y="30"> <PolyScene :directional-light="{ direction: [0.5, -0.6, 0.6], color: '#ffffff', intensity: 1 }" :ambient-light="{ intensity: 0.3 }" > <!-- meshes --> </PolyScene> </PolyCamera></template>
<script setup lang="ts">import { PolyCamera, PolyScene } from "@layoutit/polycss-vue";</script>Ambient light
Section titled “Ambient light”A uniform fill with color and intensity (default 0.4) added to every surface regardless of orientation. Use it to lift shadowed faces out of pure black — exactly what a shadowed region fades toward.
Point lights
Section titled “Point lights”Positional lights given as an array. Each has a world-space position, color, intensity, and optional castShadow. Point lights are direction-only — there is no distance falloff (they emulate three.js’s PointLight(distance: 0, decay: 0)); a surface is lit by color · intensity · max(0, n · L̂) where L̂ points from the surface to the light. Shading is flat per face — an accepted approximation of three.js’s per-fragment gradient, exact for small faces or distant lights.
Point lights are baked-mode only. Dynamic mode’s zero-JS light updates can’t express a per-face direction that varies with position, so dynamic scenes ignore
pointLightsentirely (shading and shadows). See Lighting modes.
const scene = createPolyScene(host, { camera: createPolyCamera({ rotX: 35, rotY: 20 }), // textureLighting defaults to "baked" — required for point lights. pointLights: [ { position: [-4, 4, 5], color: "#ff7755", intensity: 1, castShadow: true }, { position: [5, -3, 4], color: "#5599ff", intensity: 1, castShadow: true }, ], ambientLight: { intensity: 0.3 },});<PolyScene pointLights={[ { position: [-4, 4, 5], color: "#ff7755", intensity: 1, castShadow: true }, { position: [5, -3, 4], color: "#5599ff", intensity: 1, castShadow: true }, ]} ambientLight={{ intensity: 0.3 }}> {/* meshes */}</PolyScene><PolyScene :point-lights="[ { position: [-4, 4, 5], color: '#ff7755', intensity: 1, castShadow: true }, { position: [5, -3, 4], color: '#5599ff', intensity: 1, castShadow: true }, ]" :ambient-light="{ intensity: 0.3 }"> <!-- meshes --></PolyScene>Lighting modes
Section titled “Lighting modes”textureLighting (scene-level, with a per-mesh override) chooses how the Lambert result is applied:
"baked" (default) | "dynamic" | |
|---|---|---|
| How | Computed once on the CPU, written into each leaf’s color / atlas pixels | Resolved live in CSS calc() from scene-root variables + per-leaf normals |
| Point lights | ✅ Supported | ❌ Ignored (direction-only CSS can’t vary per position) |
| Moving the directional light | Re-bake needed for the lit surface (shadows update for free) | Zero JS — just updates a CSS variable |
| Best for | Static scenes, point lights, exact color | Live / animated directional light, interactive light dragging |
Rule of thumb: dynamic when the directional light moves every frame; baked when you want point lights or the lights are mostly static.
Cast shadows
Section titled “Cast shadows”Shadows are CPU-projected SVG surfaces (not a render-strategy leaf). Mark casters with castShadow and receivers with receiveShadow:
scene.add(parsedFloor, { receiveShadow: true });scene.add(parsedModel, { castShadow: true });scene.setOptions({ shadow: { color: "#000000", opacity: 0.3, lift: 0.02 } });<PolyScene shadow={{ color: "#000000", opacity: 0.3, lift: 0.02 }}> <PolyGround size={8} color="#d8d2c7" receiveShadow /> <PolyMesh src="/model.glb" castShadow /></PolyScene><PolyScene :shadow="{ color: '#000000', opacity: 0.3, lift: 0.02 }"> <PolyGround :size="8" color="#d8d2c7" receive-shadow /> <PolyMesh src="/model.glb" cast-shadow /></PolyScene>What to expect:
- Directional shadows work in both lighting modes and only appear for a directional light with
intensity > 0(intensity0, or no directional light, casts nothing — matching three.js). - Point-light shadows are baked-mode only and radial (each vertex projects along its own ray from the light). Set
castShadow: trueon the point light andcastShadow/receiveShadowon the meshes. - Colored multi-light shadows. Each light’s shadow is filled with the receiver lit by every other light, so a spot blocked from one colored light still shows the others’ color. Where two shadows overlap, the region composites to the both-blocked color (ambient only) — not a doubled-up black smear.
shadow.liftfloats the shadow a hair above the receiver to avoid z-fighting; the default is fine, just don’t set it to exactly0on a coplanar floor.
Parametric shadows
Section titled “Parametric shadows”By default a shadow projects every casting polygon. Set shadow: { parametric: true } and PolyCSS instead casts one low-resolution coverage silhouette per caster — far fewer DOM/SVG vertices and a cheaper projection, at the cost of an approximate (but concave- and hole-aware) outline. The exact path stays the default; parametric is purely opt-in and works in all three renderers.
// vanillascene.setOptions({ shadow: { parametric: true, definition: 32 } });// React<PolyScene shadow={{ parametric: true, definition: 32 }}><!-- Vue --><PolyScene :shadow="{ parametric: true, definition: 32 }">definition(default16) — silhouette detail. Higher → closer to the exact outline and sharper holes, but more vertices. Override it per mesh withshadowDefinition(PolyMeshTransform.shadowDefinitionin vanilla,<PolyMesh shadowDefinition>in React,shadow-definitionin Vue) so a detailed hero caster stays crisp while simple props run cheap in the same scene.style: "vector" | "pixel"—"vector"(default) traces a smooth concave contour;"pixel"greedy-meshes the coverage into blocky voxel rectangles, wheredefinitionbecomes the pixel-grid resolution (lower → chunkier). Holes (courtyards, an arena) come through in both.- Point lights are supported — each casting point light gets its own radial silhouette.
dragDefinition(vanillacreatePolySceneonly) — progressive refinement. While the directional light is being dragged the shadow renders at this lower definition for a smooth drag, then a debounced pass re-emits at fulldefinitiononce the light settles. React/Vue get the same effect by loweringdefinitionin component state during a drag.followAnimation— by default a deforming/animated caster’s shadow freezes at the last pose (re-projecting it every frame is expensive). SetfollowAnimation: trueto make the shadow track the animation; pair it withparametric+ a modestdefinitionso the per-frame reprojection stays cheap.
Live & animated lights
Section titled “Live & animated lights”- Animating the directional light: use dynamic mode — moving the light is a single CSS-variable write per frame, no JS in the paint loop, and shadows re-project automatically.
- Baked mode + a light change: cast shadows re-emit automatically (cheap SVG), but the baked lit surface stays frozen until you re-bake — call
mesh.rebakeAtlas()(debounce it to the end of a drag; the atlas raster is the one costly step). The React/Vue components re-bake automatically on prop change, so this only applies to the imperativecreatePolyScene()API. - Animating point lights: baked-mode only, so re-bake per change (or per debounced step). For continuously moving lights, prefer a single directional light in dynamic mode.