Skip to content

Releases: getlowarc/winding

Winding 1.9.1

Choose a tag to compare

@github-actions github-actions released this 02 Nov 17:14
09a30d5

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
    firstInstance from 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-engine

Winding 1.9.0

Choose a tag to compare

@github-actions github-actions released this 30 Oct 18:38
7bad151

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.25 runs the world at a quarter speed and 0 stops it: update steps 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.delta is 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/).
    createNode takes a name too.
  • node.setWorldPosition,
    node.setWorldRotation,
    node.worldRotation and
    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) and node.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; after prepare, 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, as load does: 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 -- and node.visible reads 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. onDeviceLost is told action: 'recover'
    and recovered, 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.create built 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 (create 1.7 to 1.05 s); 1.2 to 1.0 s on every launch after.
  • engine.prepare draws 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-engine

Winding 1.8.0

Choose a tag to compare

@github-actions github-actions released this 30 Oct 12:58
7102629

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:
    castShadows for castShadow turned 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-engine

Winding 1.7.0

Choose a tag to compare

@github-actions github-actions released this 09 Oct 21:15
defba26

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(): whether Winding.create can work in
    this browser -- WebGPU, and a GPU it will hand out -- as true or false, 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-engine

Winding 1.6.1

Choose a tag to compare

@github-actions github-actions released this 09 Oct 16:34
9f0faf1

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 oit on, 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.
  • 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. source on a model is null.
    • 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.

npm install winding-engine

Winding 1.6.0

Choose a tag to compare

@github-actions github-actions released this 07 Oct 21:16
328a168

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.draws counts 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 new stats.cascadesScrolled counts 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. casterExtent is 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-engine

Winding 1.5.0

Choose a tag to compare

@github-actions github-actions released this 06 Oct 19:58
05b055e

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
    (default 0.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. 0 draws 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-engine

Winding 1.4.0

Choose a tag to compare

@github-actions github-actions released this 06 Oct 18:34
c41bafb

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 the taa option)
    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, then run rests.

  • 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. setLight changes it and lightOf returns 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 = false turns 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 .ply capture'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. loadSplats returns the capture's degree.

  • .spz and .sog captures load. engine.loadSplats reads Niantic's .spz (versions 1 to 3,
    gunzipped by the browser's DecompressionStream, 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. .spz version 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 .spz written by splat-transform from a .ply, each splat within the format's
    own quantisation of the .ply's. A .spz written without coordinates, as splat-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.exposure still applies, as
    compensation. run keeps 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.html in 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 fast acos, 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

Winding 1.3.0

Choose a tag to compare

@github-actions github-actions released this 05 Oct 17:05
67e00f3

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.ts and src/bench.d.ts ship with the package, written by
    hand from the API reference: every export, every option, and an overload so scene.pick returns a
    3D hit for a Camera and a 2D one for a Camera2D. 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.loadSplats said its result was an environment. scene.bounds and
    scene.frame said only meshes count; splat clouds do too.

npm install winding-engine

Winding 1.2.0

Choose a tag to compare

@github-actions github-actions released this 02 Oct 19:10
33a7210

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