WINDAGE ENGINE v1.0.0
Contents

Docs / Rendering

Sky and environment

ProceduralSky describes a sky as radiance arriving from each direction: a zenith, a horizon, a ground and a sun disc. EnvironmentMap prefilters that sky into two cube maps, so a shader can look up reflections and ambient light. Both come from the same source, so the sun you see, the sun that lights the scene and the sun that metal reflects stay the same sun.

ProceduralSky

public init(zenith: SIMD3<Float> = SIMD3(0.28, 0.45, 0.85),
            horizon: SIMD3<Float> = SIMD3(0.62, 0.72, 0.88),
            ground: SIMD3<Float> = SIMD3(0.22, 0.21, 0.18),
            sunPitchDegrees: Float,
            sunYawDegrees: Float,
            sunRadiance: SIMD3<Float> = SIMD3(330, 310, 270),
            sunAngularRadius: Float = 0.05)

A second initializer takes sunDirection: in place of the two angles. All values are linear radiance, not colors. The sun is thousands of times brighter than the sky around it, and the tone map brings that range down to something a display can show.

Radiance is not irradiance

sunRadiance is how bright the disc looks when you look straight at it. A surface facing the sun receives that radiance multiplied by the small solid angle the disc covers. Use sunIrradiance as your directional light's intensity, never sunRadiance. With the default 0.05-radian disc, the two differ by about 130×. Because the irradiance is derived from the disc, a wider sun comes out both softer and stronger.

A level's sky

Level.sky is an optional ProceduralSky, saved as its own block in the level file. Every key is optional, so a block that names only sunPitch is valid. This is the sample's dusk ruins:

class="p">"sky" : {
  class="p">"zenith" : [0.15, 0.17, 0.21],
  class="p">"horizon" : [0.3, 0.28, 0.26],
  class="p">"ground" : [0.1, 0.09, 0.07],
  class="p">"sunPitch" : 11,
  class="p">"sunYaw" : 240,
  class="p">"sunRadiance" : [38, 24, 13],
  class="p">"sunAngularRadius" : 0.1
}

Read it through Level.resolvedSky. That property also handles older files that set the sun with sunPitch and sunYaw in settings and have no sky block. With neither, it returns the default ProceduralSky().

An overcast level is darker and flatter overall, not just lit by a dimmer sun. The ruins' first sky kept bright blue-sky values and only dimmed the sun, and the stone washed out to near-white.

EnvironmentMap

public init(device: MTLDevice,
            sky: ProceduralSky,
            size: Int = 128,
            irradianceSize: Int = 16) throws

It throws an EnvironmentMapError naming the cube the device would not create: .specularTexture or .irradianceTexture.

TextureWhat it holds
specularThe sky convolved with the GGX lobe, one roughness per mip. Mip 0 is the sky itself, and the last mip is nearly uniform. Sample it at roughness * Float(specularMipCount - 1).
irradianceThe sky convolved with a cosine lobe: what a diffuse surface facing each direction receives. Sixteen texels a side is enough, because the result has almost no detail.

Add allocations to your residency set. Building a map is a load-time cost, not a per-frame one: 92 ms in a release build and 2.6 s in a debug build. EnvironmentMap is nonisolated and Sendable, so a game that would rather not spend that on the main actor builds it in a Task.detached and hands the map back.

Built on the CPU

The prefilter runs on the CPU deliberately. The parts that can be wrong are the cube face directions, the importance sampling and two convolutions, and as CPU code they have tests that need no GPU. Those tests caught three bugs: fireflies at high roughness, fixed by quadrupling the sample count per mip; an irradiance integral off by a factor of π; and a black lower hemisphere. One limitation is stated plainly: this prefilters a function, not a photograph. It can't load a captured HDR panorama.

How the sample uses them

The sample rebuilds its environment whenever a level loads, from that level's sky:

let built: EnvironmentMap
do {
    built = try EnvironmentMap(device: engine.device, sky: level.resolvedSky)
} catch {
    print(class="p">"WindageSample: no environment — metal will have nothing to reflect (\(error))")
    return
}
if let previous = environment {
    engine.residencySet.removeAllocations(previous.allocations)
}
environment = built
engine.residencySet.addAllocations(built.allocations)
engine.commitResidency()

It takes its sun from the same sky: level.resolvedSky.sunDirection for direction and cascades, and level.resolvedSky.sunIrradiance for intensity. If the environment were lit from one direction and the scene from another, it would look like a shading bug.

The background is drawn from the same cube that surfaces reflect. The sample's sky pass is a full-screen triangle that turns each pixel into a ray and samples mip 0 of specular. A flat clear color behind a metal object that reflects a sky with a sun in it would show two different skies in one picture.

Divide irradiance by π. The irradiance cube holds light integrated over the hemisphere, and Lambert's BRDF is albedo / π. Skip the division and every surface lit by ambient light is π times too bright.

The sky's matterLight lights dust. See particles.