The sun is wherever the windows point
Duimstok is a 52.80 m² ground floor you walk through in a browser tab. It has three windows. For a while it also had lighting that was, on paper, entirely reasonable: a hemisphere light at 2.2, a flat ambient at 0.55, and a sun outside that solid ceilings dutifully blocked.
It looked terrible, and not in a way that pointed anywhere. Rooms read flat. Furniture looked pasted on. The whole thing had the particular deadness of a scene where every surface receives the same amount of light from every direction, which is exactly what it was.
What the room looked like before
The useful move was to stop reasoning about it and turn things off. Killing both fills showed that the sun was getting in through the garden window — but only as a sliver about 30 cm deep at the sill. At their real intensities the two fills erased that sliver completely.
Three separate faults fell out of that one test. Every surface of the living room
was rendering at the same value. The ceiling read brown, because the hemisphere
light's ground colour was the thing lighting it, and three.js defaults
that colour to 0x6b6255 — the mud of bare earth rather than sunlight
coming off a floor. And nothing indoors cast a shadow at all, which is why the
furniture floated.
The fills were doing all the work and none of it well. What a room actually needs is light that arrives from where the openings are.
Deriving the sun from window normals
The obvious way to place a sun is to pick a compass bearing. South-west, thirty degrees up, done. It works, and it is wrong here for a reason that has nothing to do with graphics.
Duimstok loads any floor plan, including ones you draw yourself. A hardcoded bearing lights the building it was tuned against by design, and every other building by luck. It also puts a fact about one specific house into an engine whose entire job is to contain no facts about any specific house.
So the sun is derived. The averaged outward normal of every window is the direction the house faces; put the sun there and daylight is guaranteed to reach an interior, whatever plan is loaded. That is the whole idea, and it is about twenty lines:
/**
* Which way the sun is, derived from where the windows are.
*
* The averaged outward window normal is the direction the house *faces*, so
* putting the sun there is what guarantees daylight actually reaches an
* interior. A hardcoded compass bearing would light this house by design and any
* other plan by luck — and would put a measurement of one building into code
* that is supposed to be generic (ADR-0006).
*/
function sunFrom(level: BuiltLevel): THREE.Vector3 {
let x = 0;
let z = 0;
for (const opening of level.windows) {
x += opening.normal.x;
z += opening.normal.z;
}
const length = Math.hypot(x, z);
if (length < AZIMUTH_EPS) {
// Either no windows, or windows on opposite walls that cancel. Nothing can
// be inferred, so pick a bearing rather than pointing the sun at nothing.
[x, z] = FALLBACK_AZIMUTH;
} else {
x /= length;
z /= length;
}
const flat = Math.cos(SUN_ELEVATION);
return new THREE.Vector3(x * flat, Math.sin(SUN_ELEVATION), z * flat);
}
The elevation is fixed at 32°. The AZIMUTH_EPS branch is the case
worth noticing: windows on opposite walls cancel, and the averaged normal comes
out as noise near zero rather than as a direction. Below a length of 0.15 there is
nothing to infer, so it falls back to a fixed bearing rather than aiming the sun
at nothing in particular.
One SpotLight per window, outside the opening
A directional sun gets one beam into the building. It does not light the rooms
that beam misses, and a house has more openings than the sun has angles. Each
window therefore gets its own SpotLight, with its intensity scaled by
the opening's area and a cool sky tone set against the warm sun.
The part that is easy to get backwards: that spotlight sits outside the opening, not inside the room. Two and a half metres outside, aimed at the floor well inside.
Putting it outside means the wall does the masking. The beam that lands on the floor is the shape of the hole, for free and correctly, and anything standing in that beam casts a real shadow into it. A light placed inside the room would light the room evenly and mask nothing at all — you would get brightness without the one thing that reads as daylight.
The 2.5 m stand-off is not about masking, though. It is about falloff. A point source close to an opening falls off as 1/r² across the room, which leaves a hot pool by the window and gloom four metres in. Standing it well back flattens that curve so the light carries. A real window is an area source; this is the closest a point gets to behaving like one.
The cone is 45° wide with a penumbra of 0.95. Both numbers are tuned against the wall rather than against taste. Every degree past 45 is shadow-map resolution spent on light the wall is going to block anyway, and it shows up as a staircased shadow edge. A hard angular cutoff, meanwhile, draws a second straight edge across the ceiling that reads as a bug rather than as a light.
Fitting the directional light's shadow camera
The sun's shadow camera is fitted to the house's bounding sphere rather than left at a fixed frustum. This is a small change with a visible payoff.
A fixed ±20 m frustum spreads a 2048² shadow map over 40 metres — about 2 cm per texel. At that density the sharp edge of a window patch stops being an edge and becomes a smear. Sized to the building it is nearer 0.6 cm per texel, and the patch gets its edge back. Same map, same cost, three lines of arithmetic.
Shadow acne on every ceiling, and why normalBias could not fix it
This is the part that cost real time, and it is the part most likely to be useful to someone else.
Once light was arriving sideways through windows, every ceiling in the house broke
out in shadow acne. The usual remedies did not help. Increasing
bias ate the contact shadows. Increasing
normalBias did nothing whatsoever.
normalBias works by offsetting the shadow lookup along the surface
normal, which moves the sample away from the surface as seen by the light. When
the normal is perpendicular to the light direction, that offset moves the sample
along the surface instead of away from it. It cannot help. And indoors,
perpendicular-to-the-light is not an edge case — it is every ceiling in the
building, because the light comes in sideways through a window and the ceiling
sits permanently at the terminator.
What made it hard to see was the shape. The acne took the form of
ExtrudeGeometry's triangle fan, so it looked like a tessellation
problem. We went looking in the geometry for a while before accepting it was a
depth-precision problem wearing a geometry costume.
The fix is one property, set on every material in the cache:
shadowSide: THREE.BackSide,
Shadow maps are then rendered from the back faces of everything. Instead of comparing a lit surface's depth against its own depth in the map — where the two agree only to within a texel — the comparison moves a whole wall thickness away, to the far side of the object. The acne disappears, and the contact shadows stay.
This has a precondition that is worth stating loudly, because breaking it fails
silently. Back-face shadows are only valid because every object in Duimstok is a
closed solid — boxes, extrusions, lathes, never a bare surface. A single
PlaneGeometry used as a floor rather than a slab would stop casting a
shadow entirely, and the symptom would be a missing shadow, not an error.
What the rig costs
Three windows means three shadow maps, which measured at 2.1 ms per frame with 29 items placed. That is nothing here. A plan with twenty windows would need a cap, chosen by opening area or by distance to the player, and nobody has written it because nobody has drawn that plan yet.
The sun is not astronomically correct for any latitude, date or time. It is aimed to light the building it was given. If you want real solar geometry, this is the wrong model and you should compute an actual azimuth from a location.
The fills that survived are tuned against exactly one constraint: every room stays
navigable. Four rooms in this plan have no window at all, and a pitch-black hall
behind a shut door is a bug rather than realism. They sit low — hemisphere at 0.2,
ambient at 0.14 — because a prefiltered procedural sky in
scene.environment now carries most of what they used to carry alone.
That last change was measured rather than eyeballed, using a probe that renders the room and returns numbers instead of an image. Living room mean luminance went from 135.8 to 167.7; the four windowless rooms went from 101.9 to 135.0. The number that mattered most was neither of those but the ratio between them: 1.333 before, 1.242 after. Push the fills up until every room hits its brightness target and that ratio collapses toward 1.0, at which point you have a uniformly bright box rather than a room with daylight in it. That is the same flatness we started with, arrived at from the other direction.
When to do it the other way
Deriving the sun from the windows is the right trade when the geometry is user-supplied and the goal is legibility. It guarantees that daylight reaches an interior on a plan nobody has seen yet, and it keeps a measurement of one building out of an engine that is supposed to be generic.
It is the wrong trade if the building is fixed and known, in which case just aim the sun and move on; or if you need real solar position for a specific place and date, which this cannot give you; or if the visual target is photorealism, where unoccluded environment lighting — a windowless cupboard lit by a sky it cannot see — is not defensible. We keep it because it is what makes that cupboard navigable, and we would rather say so than pretend it is physics.
The general lesson is smaller than the lighting and probably more useful: when a scene looks wrong everywhere at once, turn the lights off one at a time before adjusting any of them. Three separate faults were sitting on top of each other here, and none of them was visible while the fills were on.