drawcall-ai/skills

lights

Set up lighting for Three.js scenes — choosing light combinations, shadows, and look. Use when configuring scene lighting, fixing flat or harsh lighting, or adding shadows.

First seen Jun 17, 2026

Installation

$ npx skills add drawcall-ai/skills --skill lights

Similar popular skills

Related neighbors and high-traction skills in the same topics — useful to compare before installing.

Also in this package

Other skills from drawcall-ai/skills · top by installs.

npx skills add drawcall-ai/skills

Browse all from drawcall-ai/skills

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Not declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Repository health

Default branch main
Open issues 0
Status Active

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 9,369 B
  • docs SUMMARY.md 188 B

History

  1. First seen on skills.sh
  2. First recorded snapshot · 135 installs

SKILL.md

Lights

A believable lit scene is a fixed three-part rig decided once at startup: image-based fill (an HDR on scene.environment, the dominant fill source) plus one "sun" — a directional light, here a CSM cascaded shadow map, that gives surfaces their shape and casts the shadows — plus a little ambient. The balance principle: all fill (the IBL first, then ambient) must be turned down clearly below the sun, or the shadowed side of every surface floods and cast shadows wash out. The sections below cover the available light combinations, the recipe (IBL fill + one sun + low ambient), the sun with shadows (CSM), the environment and the visible sky, balancing fill vs sun so shadows read, an intensity reference, and why you keep the light set fixed at runtime.

Light combinations

The common rigs and what each produces:

Setup Result
AmbientLight only Flat uniform lighting, no shadows
DirectionalLight only Harsh contrast, pure black shadows
Ambient + Directional Realistic outdoor lighting (sun + sky fill)
Ambient + PointLights/SpotLights Indoor scenes with local light sources

Ambient light controls how shadows appear — without it, shadows are pure black. Even in nighttime scenes, keep enough light (for example an AmbientLight) that the viewer can still see the environment.

The recipe: IBL fill + one sun + low ambient

Ambient-only or hemisphere-only lighting looks flat and gray because nothing gives surfaces form. What reads as "a real place" is image-based fill + a sun + textured materials:

  • Set scene.environment from an HDR (Market environment assets ship one) so materials pick up real image-based reflections and fill light.
  • Add one directional/CSM "sun" for shape and shadows.
  • Keep ambient low (0.2–0.4 or none).

Balance the fill against the sun, or shadows wash out (see "Balancing fill vs sun so shadows read" below). Flat ambient light plus solid-color (untextured) materials is exactly the "gray clay" look — fix it with IBL, a sun, and real textures (see the materials skill).

The sun with shadows (CSM)

Recommended for the sun: CSM (Cascading Shadow Maps). CSM replaces DirectionalLight with superior shadow quality out of the box — no need to manually configure shadow camera bounds. A lone DirectionalLight has a single shadow map covering one fixed frustum: over a large outdoor map its shadows are either blurry (one map stretched across hundreds of meters) or simply missing past a short range. For any outdoor or large scene, use CSM — its cascades keep shadows crisp from right next to the camera out to the horizon. A lone DirectionalLight with a small shadow frustum on a big map is wrong.

CSM looks like a lot of setup, but it is paste-once boilerplate — drop this helper in unchanged, then call createCsmSun(...) once and csm.update() every frame. That is the whole cost; don't trade it for a plain DirectionalLight.

import { CSM } from 'three/examples/jsm/csm/CSM.js'
import { Material, MeshStandardMaterial, Vector3 } from 'three'

// Paste-once: an outdoor sun with cascaded shadows. Returns the csm; call csm.update() each frame.
function createCsmSun(scene, camera, renderer, lightDirection = new Vector3(-1, -2, -1), intensity = 2, maxFar = 200) {
  renderer.shadowMap.enabled = true // do NOT also set shadowMap.type = PCFSoftShadowMap with CSM

  const csm = new CSM({
    cascades: 4, lightDirection: lightDirection.clone().normalize(),
    camera, parent: scene, lightIntensity: intensity, maxFar, mode: 'practical',
  })
  // fade is ignored by the constructor and only takes effect after a frustum rebuild,
  // so set it then rebuild — otherwise cascade seams stay hard.
  csm.fade = true
  csm.updateFrustums()

  const biases = [-0.00001, -0.0001, -0.0003, -0.0006]
  const normalBiases = [0.02, 0.06, 0.16, 0.3]
  csm.lights.forEach((light, i) => {
    light.shadow.bias = biases[i] ?? -0.0006
    light.shadow.normalBias = normalBiases[i] ?? 0.3
  })

  // CSM injects its shader chunks into materials lazily at render time. Apply it ONLY to the lit
  // PBR materials that actually receive shadows (MeshStandardMaterial — terrain, characters, props).
  // Splicing CSM's shadow chunks into materials with no lighting pipeline — additive sprites, points,
  // MeshBasicMaterial, and other transparent VFX/UI — breaks their shaders so they render as dark
  // quads (the classic "dark planes" over muzzle flashes and particles); those never receive shadows
  // anyway. setupMaterial OVERWRITES material.onBeforeCompile, so only re-chain a material's existing
  // hook when it actually has one — stock materials don't, and calling .apply on a missing prior hook
  // would throw.
  const done = new WeakSet<Material>()
  const original = renderer.renderBufferDirect.bind(renderer)
  renderer.renderBufferDirect = function (cam, sc, geom, material, object, group) {
    if (material instanceof MeshStandardMaterial && !done.has(material)) {
      done.add(material)
      const prev = material.onBeforeCompile
      csm.setupMaterial(material)
      const csmHook = material.onBeforeCompile
      if (typeof prev === 'function' && prev !== csmHook) {
        material.onBeforeCompile = (...args) => { prev.apply(material, args); csmHook.apply(material, args) }
      }
    }
    return original(cam, sc, geom, material, object, group)
  }
  return csm
}

Use it — and call csm.update() every frame, before renderer.render(...). Forgetting csm.update() is the one easy mistake — shadows then won't follow the camera.

scene.add(new THREE.AmbientLight(0xffffff, 0.4)) // fill, so shadows aren't pure black
const csm = createCsmSun(scene, camera, renderer)
// in the render loop, BEFORE renderer.render(...):
csm.update()

Environment & the visible sky

Set scene.environment from an HDR for image-based fill (above); the visible sky is a separate, related step. The visible sky must be an equirectangular skybox, not a flat image. To show the sky behind the scene, assign the equirectangular panorama to scene.background and set its mapping, so Three.js wraps it around the world as a skybox that stays put as the camera turns:

const sky = await new TextureLoader().loadAsync('/environment/<name>-background.webp')
sky.mapping = EquirectangularReflectionMapping // without this it renders as a flat 2D screen image
sky.colorSpace = SRGBColorSpace
scene.background = sky

Never parent a sky image to the camera — that pins a flat picture to the view instead of giving the world a real horizon.

Balancing fill vs sun so shadows read

Balance the fill against the sun, or the shadows wash out. A bright IBL environment (a clear midday sky especially) plus a high AmbientLight floods the shadowed side of every surface, so the sun's cast shadows fade to nearly invisible even when shadow-casting is wired correctly.

The IBL is usually the bigger fill source than AmbientLight — a bright sky env at full strength washes shadows on its own — so balancing only AmbientLight is not enough: also turn scene.environmentIntensity down (often ~0.3–0.6) and keep the sun clearly brighter than both, or shadows stay flat. Keep ambient low (0.2–0.4 or none) too, then confirm by eye that objects drop a readable shadow.

A correctly set-up CSM showing no shadows on screen is almost always drowned by fill, not broken — turn the fill (IBL first) down before suspecting the shadow pipeline. Flat ambient light plus untextured materials is the "gray clay" look — fix it with IBL, a sun, and real textures (see the materials skill).

Intensity reference

  • AmbientLight: 0.3–0.6 (fill light)
  • CSM lightIntensity: 1–3 (sun-like, affects entire scene)
  • PointLight / SpotLight: 1–5 with reasonable distance (e.g. 10–50)

Tone-map the HDR range with renderer.toneMapping = ACESFilmicToneMapping and exposure ~1.

Keep the light set fixed (runtime cost)

Three.js bakes the number of lights (and of shadow-casting lights) into every material's shader. Adding or removing a light — or toggling a light's castShadow — at runtime forces a recompile of all materials, which stalls the frame. Spawning a light per effect (a muzzle flash, an explosion) produces a visible hitch on every shot.

  • Decide the light rig once at startup and keep the count fixed.
  • For muzzle flashes, explosions, and pulses, do not add a light. Animate the intensity/color of one pre-created, normally-dim light, or fake the glow with an emissive material or an additive sprite. If you genuinely need moving light sources, pool a few reusable ones created up front.
  • The same applies to materials: changing a material's defines (e.g. adding/removing a map) recompiles too — prefer animating a uniform or color.