Repository navigation
Releases: getlowarc/winding
Release list
Winding 1.9.1
A new home, and cheaper torches. Winding lives in the LowArc organisation on GitHub now, and a
point or spot light's shadow draws only the casters that reach each of its views: a dungeon of 36
shadowed torches went from 13.0 to 6.8 ms of GPU time.
Changed
- Winding moved to the LowArc organisation on GitHub: https://github.com/getlowarc/winding,
with the live demo at https://getlowarc.github.io/winding/demo/. The npm name is still
winding-engine, and old GitHub links redirect. - A point or spot light's shadow draws only the casters that reach each view. Every face of a
point light drew every caster in the scene and let the GPU clip what fell outside; now each face
drawn lists the casters whose boxes reach into it, batch by batch, and draws those, through
firstInstancefrom the list. The picture is the same to the pixel. A dungeon of 36 shadowed
torches and 40 walkers, after 1.9.0's faces-that-changed: 13.0 to 6.8 ms of GPU time on Iris Xe,
21.9 ms before both, for about 0.5 ms of CPU listing. With LOD the GPU still chooses a group's
level, so those scenes draw every caster as before.
npm install winding-engineWinding 1.9.0
Ready for games. A lost GPU device is recovered from instead of ending the session, and
prepare builds a level's pipelines behind the loading screen. Meshes can be made from arrays,
and nodes hidden, tinted, lit up, found by name and placed in the world whatever their parents,
all without leaving their batches. The clock slows and stops for a hit-stop or a pause. Shadows
cost less: cascades skip the casters that don't reach them and cast a level of detail their maps
can show, and a point light redraws only the faces something moved in. And it starts faster,
from 3.1 to 2.3 s on a first launch in Electron.
Added
engine.clock.timeScale: slow motion, hit-stop and pause.
0.25runs the world at a quarter speed and0stops it:updatesteps keep their length and
come less often, and animations and particles slow with them, while a HUD scene keeps real time
so a pause menu still moves.clock.deltais the frame's simulated seconds.node.name,node.find(name)and
node.findAll(name): a node keeps its name from the glTF file, and
a model's or a level's nodes can be found by it -- spawn points and drop zones marked in Blender,
the hand bone a weapon goes in, the door to take off. A RegExp finds a family (/^spawn/).
createNodetakes anametoo.node.setWorldPosition,
node.setWorldRotation,
node.worldRotationand
setParent(node, { keepWorld }): a node placed in the world
whatever its parents, from where they are now -- a physics body's pose on a ragdoll's bone, set
root to tip in one step -- and moved to a new parent without moving: a wall piece or a car door
breaking off, a weapon picked up where it lay.node.setTint(color)andnode.setGlow(color):
colour a mesh and everything under it at runtime -- factions, classes, players, a hit's flash --
without a copy of the model per colour. Tints multiply and glows add down the hierarchy, and
the meshes stay in their batches: tinting every unit differently draws with the same calls as
tinting none. They ride in padding the per-object data already had, so nothing grew, and a
frame where tints change spends 0.1 ms on 9,000 nodes.engine.prepare(scene, camera): builds everything a scene will
draw with, behind a loading screen. Probes, decals, AO, OIT, TAA and FXAA are built the first
time a frame asks for them, and until then frames draw without them, so they appeared a few
frames into play. A scene with decals, AO, TAA and particles started 17 pipeline builds in its
first frames; afterprepare, none, and AO draws from the first frame.engine.createMesh(options)makes a mesh from arrays --
positions, and optionally indices, normals, UVs and vertex colours, with a colour, metalness and
roughness -- for terrain, generated maps and props built in code. Without normals it is shaded
flat, the low-poly look. It comes back as a model, asloaddoes: culled, merged, shadowed and
rebuilt after a lost GPU device like any loaded file.node.setVisible(visible)shows or hides a node and everything
under it -- meshes and their shadows, sprites, text, shapes, paths, tilemaps, emitters, decals,
splats and lights -- andnode.visiblereads it back. A hidden node
is not hit by a ray or a pick. Nothing is rebuilt to hide or show, so a pool of bullets or debris
is far cheaper than removing and adding: the work is one pass over the nodes in a frame where
something changed.- A lost GPU device is recovered from. A driver reset, the GPU
process crashing, or a Steam Deck waking from sleep used to end the session: the engine destroyed
itself and the page had to reload. Now it gets a new device on the same canvas and rebuilds what
it loaded in place -- models read again from where they came from, under the same material ids
and geometry offsets -- so scenes, nodes and loaded objects carry on untouched. A crashed GPU
process in Electron took 1.7 s to come back from.onDeviceLostis toldaction: 'recover'
andrecovered, a promise for when it is drawing again.engine.gpu.loseForTest()
loses the device on purpose, to test it.
Changed
- A point light's shadow redraws only the faces something moved in. Its six cube faces were
all drawn again whenever anything moved inside its reach; now only the faces the moved box
reaches into are, and the frame is the same to the pixel. A walker beside a torch redraws two or
three faces, not six. A dungeon of 36 shadowed torches and 40 walkers: 21.9 to 13.8 ms of GPU
time on Iris Xe. - Starting is faster.
Winding.createbuilt its passes' pipelines one after another, and made
16 compute pipelines synchronously, each compiled on the GPU process's main thread while
everything else waited. They now compile side by side, off that thread. A 300-character scene
in Electron on Iris Xe, launch to first frame: 3.1 to 2.3 s on a first launch, with empty
shader caches (create1.7 to 1.05 s); 1.2 to 1.0 s on every launch after. engine.preparedraws the shadow cascades both ways they draw.
The first frame to cull a batch of casters for a cascade -- the first time a crowd straddles a
cascade's edge -- paid 0.1 s of the driver's recording, once, mid-play. That frame is now one of
prepare's. The first frame of play after it went from 125-180 ms to 25 ms, and the first two
seconds from 7-19 missed frames to none.- A shadow cascade casts a level of detail its map can show. A group of levels (
MSFT_lod)
cast its shadow at the level the camera showed, into every cascade, however few texels the
cascade had for it -- and at its finest from behind the camera, which the camera's test takes for
infinitely close. Each cascade now casts the coarser of the camera's level and the one its own
resolution calls for, never finer than the mesh on screen. Nine hundred spheres in three levels,
every cascade redrawn: shadows from 5.2 to 3.7 ms of GPU time. - A shadow cascade draws only the casters that reach it. A cascade used to skip a batch of
instances only when the box around all of them missed it, so a crowd spread across a map drew
every member into every cascade. Each cascade drawn whole is now culled caster by caster on the
GPU first. Three hundred walkers, 9,000 parts, moving under a sun: shadows from 12.9 to 9.5 ms
of GPU time on Iris Xe, the near cascade from 3.0 to 0.6 ms. The far cascades, which hold the
whole crowd, cost what they did.
npm install winding-engineWinding 1.8.0
Say so. A misspelt option used to vanish without a word; now the console names it and the
option it was close to. Adding or removing an object rewrites only what it changed, not the
whole scene's draw data. The logo's sails are folded, and a new page shows the same small
program in Winding, Three.js, Babylon.js and PlayCanvas, side by side.
Added
- Hello world, side by side: the same small program in Winding,
Three.js, Babylon.js and PlayCanvas, each run to check it works, with the engine versions it was
written against. - A misspelt option says so. An option a call doesn't take used to be dropped without a word:
castShadowsforcastShadowturned no shadows off and said nothing. Now the console says it
once, with the nearest name the call does take --
addLight: unknown option 'castShadows' (did you mean 'castShadow'?); it is ignored-- and the
call goes ahead as before. Every call that takes options does this, and so do the settings
objects (fog,dof,autoExposure,grading). A warning, not an error, so code that runs
today keeps running.
Changed
- The logo is redrawn: each of the four sails is folded, one half a shade darker, and the hub
is a pin with a rim and a glint of light. The same sails in the same colours, so it reads as the
same mark. The wordmarks, the favicon, the social card and the README's effects tile follow it. - Adding or removing an object rewrites only what it changed. It used to rewrite every
object's draw data and upload every batch table whole. Now only the slot it took is rewritten,
or the slot a removal moved the last object into, and a table sends only its entries that
changed. In a town of 900 buildings, each its own mesh, a frame that adds or removes one
uploaded 1.1 MB and now uploads 141 KB, and its CPU time fell from a median of about 2.9 ms to
2.0–2.8 ms, with the worst case down from 6.0 to 4.0. What it saves grows with the number of
distinct meshes. Each object's "was it visible last frame" flag now survives the change too,
so occlusion culling carries on through it. A scene with skins or morphs still rewrites
everything, since their palette and weight offsets can shift for objects that didn't change.
npm install winding-engineWinding 1.7.0
Ask first. Winding.supported() says whether an engine can be made here, so a page can show
something else where WebGPU is missing. The README has a face now -- a walker robot, rendered in
Winding, waving beside the name -- and its engine comparison is a slideshow, a chart at a time. And
CI checks only what a change reaches: a docs change in seconds, a full run split across four runners.
Added
Winding.supported(): whetherWinding.createcan work in
this browser -- WebGPU, and a GPU it will hand out -- astrueorfalse, never a throw. Ask
before creating an engine and show a still, a video or a message where the answer is no; the
README's install section shows how.
npm install winding-engineWinding 1.6.1
Off means off, for every scene. A sweep of every feature for work done with nothing to do,
beyond the scenes the last release was measured on -- occlusion culling with nothing to cull, the
sky under everything, OIT and depth of field with nothing to show, texture lookups a material does
not have, an idle character that cost a scene its static fast paths -- then of hitches and of scene
types not yet measured: merged meshes drawn direct, cut-out foliage, adds and removes, animated
materials, splats under TAA. Each fix A/B'd, the same pixels but for a few at exact depth ties.
Sponza GPU -24% against 1.6.0, measured in one page; a town of untextured buildings -12% to
-24%; 10,000 objects and an idle character, CPU -40%. And nothing builds up: a soak of everything an
app repeats, now part of the GPU suite, found and closed a dozen leaks.
Changed
- Off means off, for more than Sponza. A sweep of every feature for work done when it has
nothing to do; each fix A/B'd on Sponza (with OIT, ambient occlusion or depth of field on) and a
town of 900 buildings, the same pixels throughout (one channel moved by one level, from grading).- Occlusion culling's depth pyramid, second cull and second pass are skipped when every opaque
mesh is merged, as they are culled by view alone. Sponza: GPU -8%; the town: -13% to -24%. - The sky is drawn after the opaque geometry, where nothing covers it, instead of under all of
it first. - With
oiton, a frame with nothing blended in view skips its two
full-screen targets, its pass and its resolve; with it off, the separate blend pass is skipped too. - Depth of field whose largest blur is a pixel or less skips its three
passes: the composite took the sharp colour everywhere. Sponza at f/22 focused at 10 m: GPU -21%. - The tonemap skips colour grading's white balance, contrast and saturation when they are neutral.
- A material with none of the metallic-roughness, normal and occlusion maps skips their lookups
and the normal map's basis, and one with no emissive map skips that lookup; what they would give
is known. A town of untextured buildings: main pass -17%, GPU -12%. Textured Sponza: unchanged.
An untextured surface's normal is now exactly its own: the 1x1 default decoded a hair off flat
(4 channels in the town's frame moved by up to 2 levels). - A skinned or morphed mesh standing still costs what a static one does. A skin's bounds counted
as changed every frame, posed or not, so one idle character had every frame re-union every
box in the scene, rewrite every deforming object's draw data and rebuild the shadow casters'
boxes. Now only a box that changed counts. 10,000 boxes and one idle Fox: CPU -40% a frame;
the same pixels. Walking, as before. - Light clustering runs neither of its passes in a scene with no point or spot lights and no
decals, once a frame has cleared the counts it leaves. - The pass that picks the shadow casters' levels of detail is skipped on frames that draw no
shadow map. - TAA turned off frees its two full-size history images (15 MB at
720p, 33 MB at 1080p); turned on again, it makes them anew.
- Occlusion culling's depth pyramid, second cull and second pass are skipped when every opaque
- Merged meshes are drawn with direct draws. A merged group's triangle count and place never
change between rebuilds -- a hidden part keeps its place as degenerate triangles -- so its draws
need not be indirect, and an indirect draw pays the browser's validation. Sponza: GPU -12% to
-17%. Their depth prepass also reads the position alone, not the whole vertex: another 3-5%.
The same pixels. - Adding or removing an object no longer redraws every shadow map. Only the maps its box
reaches are redrawn, as for anything that moves -- a new object's box no longer starts as the one
last in its slot, which reached maps it did not. Its rebuild also allocates less. A town of 900
buildings, one box added: that frame 16.9 -> 14.5 ms, against 6.0 steady; the same shadows as a
full redraw. The rest is the batches and merged lists rebuilt whole, for a later release. - Cut-out foliage shades each pixel once. An alpha-masked merged group draws a depth prepass
that discards what its mask cuts, then shades with an equal depth test and no discard, so early-Z
drops the leaves behind a leaf. 1,200 double-sided leaf quads, each its own mesh: main pass -6%.
Instanced foliage is unchanged. - An animated colour, glow or roughness no longer redraws every shadow map. Only a masked or
blended material's change can reshape a shadow; an opaque one casts depth alone. - Splats under TAA sort only when the view moves. The sort was decided by the projection with
TAA's jitter in it, which moves every frame, so a still camera re-sorted every cloud each frame. - The pipelines that build texture mips are built at start, in the background, not on the
first texture streamed in during play.
Fixed
- Nothing builds up, checked for good. A soak of everything an app does over and over --
models loaded and unloaded, scenes made and dropped, spawning, every feature on and off, lights,
sprites, text, particles, decals, environments, render targets, probes, 2D levels, whole engines
made and destroyed -- counting every GPU buffer and texture to its destroy(), every pipeline and
shader made, and that everything let go of is collected. It is now a step of the GPU suite. What
it found:- Every environment loaded and every probe captured compiled its shaders anew, and the
pipelines they keyed stayed in the cache for good: three for each; every render target, two.
They are compiled once a device now, and the same sky once. - A removed reflection probe kept its slot, so removing and recapturing probes doubled the
cube array again and again (+12 MB in six rounds of the soak). Slots are reused, and a scene's
last probe removed frees the array. - A dropped 2D scene's GPU buffers waited for garbage collection -- which GPU memory does not
prompt -- with the scene's ~650 KB of heap, and the first 2D scene was held for good. A 2D
scene's buffers are now freed 120 frames after it was last drawn. - A destroyed engine stayed alive through a canvas the page kept, as a component that remounts
or an editor that reuses it does: the context still named the device, and a listener on the
device everything else. The canvas is unconfigured and the listeners removed. - A loaded model kept its whole file: its JSON and every buffer, 9.4 MB of heap for Sponza;
0.4 MB now.sourceon a model isnull. - The decal texture array was kept after the last decal was removed, and the sprite pass held the
last texture and font it drew; a 3D scene's set of changed sprites kept every entity ever
changed. Each is let go of now. - A font's atlas grew for good: a glyph once drawn was kept, and a script laid out a word at a
time adds one for every new word, so changing text doubled the atlas until it passed the device's
limit and threw. At 4096 by 4096 it starts again, empty, and text on screen draws its glyphs back
before it is packed; the kerning memo is bounded too. - Point and spot shadow layers were kept at the most ever on screen: twelve shadowed point
lights left 128 layers, 128 MB at the default size, after they were gone. Under a quarter used
for 120 frames, the array shrinks to what is drawn. - A clip joined over and over -- a hit reaction, a footstep -- added a track each time, kept
and sampled for good once finished. Joining a clip replaces a finished run of it; past 64 tracks
on a layer the console says so. - A 2D view kept the padded copy of every tileset it had drawn, unloaded or not; the particle pass
and the job system held the last scene drawn. Each is let go of now.
- Every environment loaded and every probe captured compiled its shaders anew, and the
npm install winding-engineWinding 1.6.0
Fewer draws, less overdraw, shadows that scroll. Every mesh's geometry now lives in one
arena, and every mesh drawn once is pooled with the others of its material into one draw; the
sun's shadow maps scroll with the camera instead of being redrawn; shadow passes read positions
alone; and the merged meshes draw their depth before they shade. Each change A/B'd before it was
kept, with the same pixels. On Iris Xe at 720p: Sponza 4.64 -> 4.15 ms a frame, 256 point lights
9.17 -> 6.64 ms, a town of 900 separate buildings 14.8 -> 4.0 ms of GPU.
Changed
- Merged meshes draw their depth first. With no depth prepass, a pixel's cost depended on draw
order: a surface drawn before the wall in front of it was shaded and then covered. The opaque
merged groups now draw depth alone first, and then shade only where they match it, so every
pixel they cover shades once, whatever the order; other opaque meshes test against that depth
too. Sponza walking the nave: main pass -19%, GPU -16%; with 256 point lights, the main pass
-29% to -34%. A town of 900 buildings, one group: no measurable cost. The same pixels, but for
one in 900,000 where two surfaces meet at exactly equal depth. GPUs that hide overdraw in
hardware (Apple's) gain nothing from it and pay its depth draw; unmeasured. - Shadow passes read positions alone. The geometry arena keeps every vertex's position a
second time, by itself, and a depth-only shadow draw reads those 12 bytes instead of the whole
60-byte vertex. Sponza under a turning sun, every cascade redrawn each frame: shadow passes -10%.
The same pixels; vertex memory +20%. - Big meshes are drawn a chunk at a time. Each mesh's triangles are boxed in runs of 128 at
load. A strip of a scrolled shadow cascade draws only a mesh's chunks that reach it -- a strip
is thin, and its cost was every vertex of every mesh it touched -- and a merged mesh's chunks
out of view are dropped from the camera's draw as its whole was. Sponza walking the nave, against
the last change: GPU -8%, the nearest cascade's strips 0.49 -> 0.18 ms; the same pixels. - Their shadows too, when a shadow map is drawn whole. Each material's merged meshes also
keep a list of every one's triangles, copied once, and a shadow view drawn whole -- a cascade
under a turning sun, a point or spot light's -- is a draw per material rather than per mesh.
A strip of a scrolled cascade stays mesh by mesh, where culling each mesh to the strip skips
more. A town of 900 buildings under a turning sun: frame 17.9 -> 10.1 ms, CPU 2.4 -> 0.9 ms. - Meshes drawn once each share one draw call per material. A scene of distinct meshes -- a
level, Sponza, a town of separate buildings -- was a draw call per mesh, and each costs the GPU
about 7 us whether it draws anything or not. Now every mesh part drawn once is pooled with the
others of its material into one indexed draw, its vertices read straight from the geometry arena;
a part out of view keeps its place as triangles of one repeated vertex, which the GPU discards,
and only a part that comes into view or leaves it is rewritten. A town of 900 buildings: 900
draws -> 1, GPU 14.8 -> 4.0 ms, frame 23.3 -> 9.6 ms; Sponza: 103 draws -> 25, GPU -14%. The same
pixels. These parts are culled to the view, not by occlusion; meshes drawn many times are drawn
instanced, as before.stats.drawscounts the calls. - Every mesh's geometry lives in one arena. One vertex buffer and one index buffer for every
primitive loaded, where each had its own; a pass binds them once instead of per batch, and a
freed range is reused by the next load. Groundwork for drawing many meshes in one call. - Identical materials are one material. A material identical to one already loaded -- every
factor but its name, and the same textures -- shares its id, so the same asset loaded many
times is one material, not one each. A material an animation drives is never shared. - Off means off: bloom at strength 0 draws none of its passes. A strength of 0 mixed no bloom
in, but its nine passes were drawn all the same: 0.36 ms a frame at 720p on Iris Xe. - A scene with no point or spot lights skips their loop. The shader that loops over them is
compiled out, in pipelines built in the background the first time such a scene draws; until then
it draws with the loop, as before. Sponza with only the sun: forward pass 5% faster. - A moving camera scrolls the sun's shadow maps instead of redrawing them. When a cascade's box
steps along its grid and nothing else changed, its map keeps every texel where it is -- the map
wraps, and only its origin moves -- and only the strip the box moved onto is drawn. The same
shadows: a scrolled map matches one drawn whole to within a level on a few dozen channels.
Sponza at 720p on Iris Xe, walking the nave: shadow passes 3.9 -> 0.55 ms a frame, the whole
frame 11.9 -> 7.6 ms. The newstats.cascadesScrolledcounts them. - Every caster in the scene casts into every cascade. A cascade's depth now spans the scene
along the light, rather than reaching four cascade radii toward it -- so a tall building far up
the light no longer loses its shadow -- and stays put while the camera moves, which is what
lets a cascade scroll.casterExtentis deprecated and no
longer used. - Each sun cascade draws only the casters that reach it. Every caster went into every cascade,
to be transformed and clipped away by the GPU; now a batch whose box lies outside a cascade is
not drawn into it. The same shadows. A town of 900 separate buildings: shadow passes 0.40 ->
0.22 ms a frame. Sponza, whose cost is a few large meshes every cascade reaches, is unchanged.
npm install winding-engineWinding 1.5.0
Faster where it was slow, measured. A speed audit of the engine against other engines'
techniques, each change A/B'd on Sponza, a million splats or 100,000 objects before it was
kept: splats too small to see are left out, the sun's shadows stay cached while the camera
moves, a frame where a few things move touches only those, and crowded lights shade faster.
Drawing tiny particles as points was measured slower and dropped.
Added
- Splats too faint and small to show are left out.
renderer.splatCull
(default0.5): a splat whose opacity times the pixels it covers is under it is left out of the
sort and the draw, which a sub-pixel splat costs a quad of all the same. A million splats seen
whole at 720p on Iris Xe: 27.4 -> 16.8 ms, 0.06% of channels moved by more than 8 levels; a
capture seen close: unchanged.0draws every splat as before.
Changed
- The sun's shadows are redrawn far less while the camera moves. A cascade's box was snapped
to whole texels and its depth range not at all, so a moving camera made a new matrix -- and a
redraw -- for every cascade nearly every frame. Snapped to a 16-texel grid, depth included, with
a grid step of margin, a cascade holds still until the camera has moved that far, and the cache
keeps it. Sponza at 720p on Iris Xe, camera moving: cascades redrawn 4.00 -> 0.38 a frame, GPU
10.7 -> 6.2 ms. Texels are 1.5% larger. Anything moving inside a cascade still redraws it at once. - A frame where a few things move no longer looks at everything that didn't. Composing
transforms, updating world boxes, the scene's extent and the draw-data upload each walked every
node or renderable to find the ones that moved. The transform store now lists what was marked
dirty and what moved, composing those and their subtrees, and the rest follow the list. The
extent is grown by the boxes that moved, recomputed whole only when one leaves a face of it.
100,000 objects with 2 moving: CPU 2.3 -> 1.2 ms a frame (transforms 0.41 -> 0.02, draw data
0.58 -> 0.06, bounds 0.77 -> 0.3). Past 4,096 movers, or an eighth of the nodes, it is the
whole pass as before. - Many overlapping lights cost less to shade. Profiled, most of their cost was walking each
cell's light list rather than shading: every light was two loads, the second waiting on the
first. Lights are now read four at a time, their indices and then their positions, before any
is tested. 256 lights in Sponza at 720p on Iris Xe, forward pass: 17.3 -> 14.1 ms at 3 m radius,
10.0 -> 8.9 ms at 2 m, unchanged at 1 m and with few lights. The picture is the same.
npm install winding-engineWinding 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-engineWinding 1.3.0
Types. Winding ships TypeScript types, written by hand from the API reference and checked
against its examples in CI, so an editor knows every call and option -- still with no dependency
and no build step.
Added
- TypeScript types.
src/winding.d.tsandsrc/bench.d.tsship with the package, written by
hand from the API reference: every export, every option, and an overload soscene.pickreturns a
3D hit for aCameraand a 2D one for aCamera2D. They need no@webgpu/types, and merge with
it where a project has it. CI type-checks the reference's examples against them, and a Node check
fails if a public method is added without a type.
Fixed
- Docs.
engine.loadSplatssaid its result was an environment.scene.boundsand
scene.framesaid only meshes count; splat clouds do too.
npm install winding-engineWinding 1.2.0
Four limitations gone. Smoke sorted particle by particle, tilemaps that hold up zoomed out,
shadows from open surfaces, and text in the world's shaped scripts -- each shown wrong in 1.1.1
before it was fixed, none of it adding a dependency.
Added
- Alpha particles are sorted. A
blend: 'alpha'emitter's live particles are put far to near on
the GPU every frame -- keyed by log depth, a counting sort, an indirect draw -- so dense smoke no
longer shows a far particle over a near one, and dead slots are not drawn at all. About 0.2 ms
for an emitter of a thousand on Iris Xe. - Tilemaps are mipmapped. Each tileset is copied with every tile alone in a cell, its edges
stretched out, and mipped down to tiles two texels across, so a map zoomed far out is filtered
instead of shimmering, and no tile takes in its neighbour. - Open surfaces cast shadows from either side. A mesh with an edge only one triangle uses --
a plane, a roof of one sheet -- is drawn whole into shadow maps; closed meshes keep their
back faces, and with them freedom from acne. Found at load, about 40 ms for Sponza. - Text in shaped scripts. Arabic, Hebrew, the Indic scripts, Thai and others are drawn a word
at a time, shaped by the browser; Thai, Lao, Khmer and Burmese break between words though they
have no spaces; a line that starts right-to-left runs right to left.
Fixed
- A burst-only emitter that burst again lost the particles of the last burst while they had
seconds to live: its ring held one burst, and a grown ring started its next births over the
oldest live particle. - An emitter in the next scene drawn could take over another scene's particles, still alive,
when the two emitters' nodes had the same number in their scenes.
npm install winding-engine