Repository navigation
Winding 1.4.0
Better pictures, measured. Temporal antialiasing, soft shadows from a light's size, auto
exposure, splats whose colour changes with the view and that load from .spz and .sog, and
cheaper ambient occlusion -- each A/B'd on Sponza or a real capture before it was kept, and what
was tried and dropped says so below. And the GPU suite runs in CI now, on every push.
Added
-
Temporal antialiasing.
renderer.taa(and thetaaoption)
moves the camera a fraction of a pixel each frame, by a 16-point Halton sequence, and blends each
frame with the history of the ones before it: reprojected from the depth buffer and the camera,
fetched by Catmull-Rom, clipped to the neighbourhood's colours in YCoCg, blended a tenth new and
weighted by luma, trusting the history less as it moves. It takes FXAA's place on canvas frames.
On Sponza at 720p against 16x supersampling, edge error 9.3 -> 6.2 still and 9.8 -> 9.3 panning,
for about 1.2 ms a frame on Iris Xe -- with material textures half a mip sharper, which three
other ways of keeping surfaces crisp were measured against: a full mip sharper (+3.3 ms), AMD's
CAS sharpening after it (cheaper, and worse: it sharpened the noise), and a running average for
a still view (further from supersampling). Soft shadows' grain turns each frame and averages
away. A still view draws 24 more frames to settle, thenrunrests. -
Soft shadows.
scene.addLight({ size })-- a point or spot light's radius, a directional
light's angle across -- casts percentage-closer soft shadows (PCSS): a blocker search, then a
filter as wide as the penumbra the gap between caster and receiver makes, so a shadow is sharp
where an object meets the floor and soft far from it. 8 search taps and 12 filter taps on a Vogel disk turned
per pixel, so a wide penumbra is grain, not bands. Compiled in only while a light that casts has
a size.setLightchanges it andlightOfreturns it. Not cheap: a real-sized sun takes Sponza's
forward pass from 5.7 to 8.8 ms at 720p on Iris Xe (the first cut took it to 12.3).
renderer.softShadows= falseturns it off for every light at
once, a quality setting, leaving each light's size as it is. -
Splats change colour with the view. A
.plycapture's higher spherical harmonics (f_rest_*,
degree 1 to 3) are kept, as half floats, and evaluated from the direction each splat is seen
from. A compute pass works them out once a visible splat, whenever the cloud is sorted again, so
a still view pays nothing: in the vertex shader they ran at every corner of every quad and doubled
the draw (205k splats, 720p, Iris Xe: 2.19 -> 4.65 ms); as a pass, 1.4 ms while the view moves,
0.16 ms while it is still. Each degree is an override constant, so a capture without them pays
nothing.loadSplatsreturns the capture'sdegree. -
.spzand.sogcaptures load.engine.loadSplatsreads Niantic's.spz(versions 1 to 3,
gunzipped by the browser'sDecompressionStream, turned from its y-up axes to the.ply's) and
PlayCanvas's.sog(version 2: a zip read by hand, entries stored or deflated, its WebP images
decoded exactly through WebGPU, since a 2D canvas premultiplies alpha and would change the
bytes). Both with their harmonics. Still no dependency..spzversion 4 is zstd, which no
browser unpacks, and says so. Checked on real files: Niantic's own sample.spz, and a.sog
and a version 3.spzwritten bysplat-transformfrom a.ply, each splat within the format's
own quantisation of the.ply's. A.spzwritten without coordinates, assplat-transform
writes them, comes out the other way up: the spec says RUB, and the header cannot say otherwise. -
Auto exposure.
renderer.autoExposure(true, or
{ min, max, brighten, darken }) sets the exposure from the image: a 64-bin histogram of log
brightness, its darkest and brightest tenths left out, puts the scene at middle grey, and the
exposure eases there at so many stops a second. Two small compute passes, with no read back on the
way: the tonemap reads the result on the GPU.renderer.exposurestill applies, as
compensation.runkeeps drawing while it eases and rests once it settles. -
The GPU suite runs in CI.
test/gpu.ci.js(npm run test:gpu:headless) opens
test/gpu.htmlin headless Chrome, driven over the DevTools protocol with Node's own WebSocket,
so nothing is installed. CI runs it on every push, on a runner with no GPU, where SwiftShader
compiles and runs every shader: a WGSL mistake now fails the build instead of waiting for someone
to open the page.
Changed
- Ambient occlusion is 14% cheaper. A half-resolution pass now works out each block's view depth
and normal once, as XeGTAO does, and the occlusion, its blur and the composite read that instead of
rebuilding them from the depth buffer -- the composite read the depth buffer nine times a pixel,
and now reads it once. 2.21 -> 1.91 ms on Sponza at 1280x720 on Intel Iris Xe, measured A/B in
one page. The look is the same but for a pixel's width at screen borders and the thinnest
geometry. Walking the horizons on the half-resolution data, a fastacos, and depth turned to
position without the matrix were measured too, and gained nothing.
Fixed
- A GPU check too strict about lines. The 2D debug-circle check read the one pixel where two
of the circle's segments meet; which pixel a line's end covers is up to the implementation, and
SwiftShader chose the other one.
npm install winding-engine