New

dot-lighting

A world's lighting as a document rather than a scene — sun, ambient, fog, a procedural sky and the post-processing that goes with them, read from what a map file or a content pack carries, with quality profiles so one world reads the same on a desktop and in a browser.

Lighting that lives in a .tscn can only ship with the build. Lighting that is a dictionary can come out of a map file, a content pack downloaded at run time, a server’s configuration or a level editor — and a game that reads one reads all four.

var doc := DotLightDocument.from_dictionary(manifest.get("lighting", {}))
DotLightRig.apply(world_root, doc, DotLightProfile.web())

Requires dot-core.

This is not dot-fx, on purpose

dot-fx is built on the rule that an effect is safe to drop — it is pooled, budgeted and distance-culled, and nothing breaks when one never plays. Lighting is neither of those things: it is not an event, and a world that quietly drops it is a world nobody can see.

Why a document

It matters most for converted worlds. A map authored in another engine describes its own lighting — where the sun is, what colour it is, how far the fog reaches, which sky is behind it — and an importer that reads the geometry and leaves that behind produces a world that is geometrically perfect and looks like nothing in particular. Every map ends up under whatever one sun the game happened to hardcode.

Absent is not zero

Every field is optional, and a document that says nothing about the sun is different from one that says the sun is black. The first keeps whatever default the consumer holds; the second means black. has_sun and has_ambient exist so a renderer can tell those apart, because they are different instructions and a world lit by the wrong one looks broken in a way nothing reports.

A document is the world; a profile is the machine

DotLightDocument says what the world is, and is the same on every machine that loads it. DotLightProfile says what this machine will draw of it. Folding the two together is how a world ends up looking different because somebody turned their settings down — which, for a world whose surfaces are read for speed, is not a cosmetic difference.

high() web() low()
tone mapping yes yes yes
glow yes yes no
fog yes yes yes
sun shadows yes no no

A fourth profile, DotLightProfile.painted(), starts from web() in a browser and high() elsewhere and adds a softer look for flat-coloured worlds: a higher tone-map white, a little contrast and saturation, ambient occlusion, a fill light, and a dimmer sun and lower exposure under the Compatibility renderer.

The browser drops the expensive half and not the cheap one. A shadow pass scales with how much geometry is in frame, which on a large level is all of it; glow is a fixed cost in screen pixels that a phone-class GPU handles. Dropping glow first is the mistake the profiles exist to prevent.

A profile also says whether the sky continues below the horizon. sky_below_horizon is off by default, because an enclosed level has geometry under its horizon and a bright band there reads as a hole; turn it on for a world that floats inside its skybox — the platforms-in-the-sky shape of every ride-the-ramp and jump-the-gap map — or every glance down between two platforms looks at a dark floor that is not there.

Tone mapping survives every tier, including the lowest. Godot’s default is LINEAR, which is not a tone map — it clips. A filmic shoulder is why a bright sky and a dark wall can both be readable in one frame instead of one being white and the other black, and it is a curve applied to a pixel that was going to be written anyway.

What it can change, and what it cannot

A world drawn from a baked lightmap has its lighting in its textures: its materials are unshaded, and a directional light added beside them lights nothing they own.

So the sun here lights the characters, the props and anything else with an ordinary material. What changes how the baked world itself looks is the post-processing — the tone map, the fog, the glow and the colour behind everything. That is not so much a limitation as the division of labour a baked world already has, and those four are most of the difference between a faithful conversion and one that feels like the engine it came from.

A contact shade where boxes meet: DotContactShade

The Compatibility renderer has no ambient occlusion, so in a world built of flat boxes a crate meets the floor and a wall meets the ceiling with no shade in the corner, and nothing seems to stand on anything. Hand DotContactShade the boxes once at load, DotContactShade.box(transform, size, casts, shaded) for each, and DotContactShade.node(boxes) returns one MeshInstance3D of thin bands that darken both faces of every concave crease. It is its own mesh rather than the game’s, one unshaded draw call for the whole map, and nothing runs per frame. casts is false for glass and water; shaded is false for a prop whose bounding box should darken the floor round it but take no bands of its own. mesh(), material() and build() are there for a game that draws the bands itself.

Baked light for a world built in code: DotLightBake

A map generated from a document at load time does not exist in the editor, so the editor has nothing to bake. The game builds it exactly as a client would and copies the drawn meshes into a scene with a LightmapGI (DotLightBake.prepare), and the editor bakes that scene through the runner behind the --dot-bake-lightmaps= flag. DotLightBake.store saves one LightmapGIData per map. At runtime the client rebuilds the same meshes under the same names with the same second UV set (DotLightmapSheet, a deterministic packer for meshes of flat quads) and calls DotLightBake.attach(map_node, path, DotLightBake.fingerprint(parts)), which returns a DotResult. A bake made from other geometry is refused and the world is drawn with its live lights; a headless process refuses and touches nothing, and can_draw() says whether this process can. Baking needs Vulkan.

Three things that are easy to get wrong

  • A brightness is not an energy. Worlds from the older BSP-family formats write a light’s compiled intensity alongside its colour, and it ranges from 20 to 600 across eight ordinary maps. Handing that to Light3D.light_energy gives a sun six hundred times too bright on one map and twenty times on the next. It is carried unconverted in the document and curved against a reference by the rig, because what to do with 600 is a renderer’s decision.
  • A yaw in one handedness is not a yaw in the other. Converting pitch and yaw separately between a Z-up left-handed world and Godot’s Y-up right-handed one produces a sun that rises in the north, and nothing about the result looks wrong enough to question. DotLightRig.sun_rotation builds the direction in the source convention and swaps axes once, on the vector.
  • A sun straight overhead has no looking_at. It is parallel to the default up vector, and a basis cannot be built from two parallel vectors. Worlds with an exactly vertical sun are ordinary, not a corner case, and the suite asserts the result is a number rather than a NaN.

The glow threshold is under 1.0 on purpose

A world lit by a baked 8-bit lightmap has nothing above white in it anywhere, so an HDR glow threshold finds nothing to bloom at, on any map, for ever. Lowering it is what lets the brightest surfaces — the strip lights and signage a level is signposted with — read as light rather than as paint. Raise it above 1.0 for a world whose materials genuinely emit.

No autoloads

A lighting rig belongs to a world. A process running a server and a client, or holding two worlds while one loads over the other, needs two of them — and a global would make that the one arrangement this family cannot have.