From 70c290a7cc6e3f166927a69e690f5229c9abccbd Mon Sep 17 00:00:00 2001 From: Soumya Snigdha Kundu Date: Tue, 28 Jul 2026 20:22:53 +0100 Subject: [PATCH 1/5] Upload log-u8 density grids directly to GPU in reduced-motion mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Density count grids ship as log-u8 (§29). The client decoded that to f32 (`lodDecodeLogU8`), then `lodWriteGridTexture` re-encoded it with the same formula before `texImage2D` — at the wire's own max the two cancel exactly, so both passes rebuilt the bytes they started from. Reduced motion made that pure waste: it pins normMax to `d.max` and runs no easing, yet `lodStartNormAnim` still re-encoded and re-uploaded once, so a reply cost one decode, two encodes, and three full-grid allocations. Count-only grids now upload the wire buffer as their R8 texture and keep no f32 grid; because the easing paths skip a missing grid, `lodStartNormAnim` returns early and its re-upload goes with it. 6.3 ms and 1152 KB per reply at 512x384. The gate stays narrow — count-only, at the wire's max. Every reply sets `enc: "log-u8"` and mean-color replies add `rgba` on top, so log-u8 alone proves nothing: those grids upload RGBA8, need the counts in the `1 - (1 - a_pt)^k` exponent, and still read `d.grid` for legend hover. And only reduced motion pins normMax to `d.max` — eased textures are written at other maxima, so animated transitions keep the round-trip. `lodUploadGridBytes` now does the texture setup for both paths, so their unpack and sampling state cannot drift. --- js/src/45_lod.ts | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/js/src/45_lod.ts b/js/src/45_lod.ts index 17fce771..4eef054b 100644 --- a/js/src/45_lod.ts +++ b/js/src/45_lod.ts @@ -118,10 +118,14 @@ export function lodWriteGridTexture( } } } + lodUploadGridBytes(gl, tex, data, w, h, !!rgba, filter); +} + +function lodUploadGridBytes(gl, tex, data, w, h, isRgba, filter) { gl.bindTexture(gl.TEXTURE_2D, tex); const align = gl.getParameter(gl.UNPACK_ALIGNMENT); gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); - if (rgba) { + if (isRgba) { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, data); } else { gl.texImage2D(gl.TEXTURE_2D, 0, gl.R8, w, h, 0, gl.RED, gl.UNSIGNED_BYTE, data); @@ -136,6 +140,12 @@ export function lodWriteGridTexture( gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); } +function lodUploadWireGrid(gl, u8Wire, w, h, filter) { + const tex = gl.createTexture(); + lodUploadGridBytes(gl, tex, u8Wire, w, h, false, filter); + return tex; +} + // Treat the color scale like exposure: brighten slowly on drill-in so a // smaller aggregate tile does not suddenly go hot, but recover faster when // the incoming tile needs more headroom to avoid clipping. @@ -1220,14 +1230,18 @@ export function lodApplyDensityUpdate(view, g, upd, buffers) { } } } - const grid = d.enc === "log-u8" - ? lodDecodeLogU8(buffers[d.buf], d.max) - : lodCopyGrid(view._asF32(buffers[d.buf])); // Mean point color plane (LOD doc §2), copied because exposure easing // re-reads it on every norm step, after the wire buffer may be gone. const rgba = d.rgba !== undefined ? new Uint8Array(view._asU8(buffers[d.rgba])) : null; const normStart = lodNormMax(g, d.max); - const normMax = view._prefersReducedMotion() ? d.max : normStart; + const reducedMotion = view._prefersReducedMotion(); + const normMax = reducedMotion ? d.max : normStart; + const directWire = d.enc === "log-u8" && !rgba && reducedMotion; + const grid = directWire + ? null + : d.enc === "log-u8" + ? lodDecodeLogU8(buffers[d.buf], d.max) + : lodCopyGrid(view._asF32(buffers[d.buf])); g.densityNormMax = normMax; g.prevDensity = g.density; g._densityFadeStart = view._now(); @@ -1244,9 +1258,11 @@ export function lodApplyDensityUpdate(view, g, upd, buffers) { rgba, filter, _filterKey: replyFilterKey, - tex: view._uploadGrid( - grid, d.w, d.h, normMax, rgba, filter, view._fillOpacity(g.trace.style), - ), + tex: directWire + ? lodUploadWireGrid(view.gl, view._asU8(buffers[d.buf]), d.w, d.h, filter) + : view._uploadGrid( + grid, d.w, d.h, normMax, rgba, filter, view._fillOpacity(g.trace.style), + ), lut: g.density.lut, }; // Exact scans include a view-specific sample and replace the overlay. From 8828d50884e597479359cb9a35524f7a7cb7fae8 Mon Sep 17 00:00:00 2001 From: Soumya Snigdha Kundu Date: Tue, 28 Jul 2026 21:01:09 +0100 Subject: [PATCH 2/5] Retain wire bytes on direct-wire densities so re-uploads survive a null grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A direct-wire density stored grid: null, but the home-extent restore in _requestSampleRebin re-uploads _homeDensity.grid, throwing a TypeError under reduced motion after a zoom-in-and-return. Keep a copy of the log-u8 wire bytes on the density object and re-upload those when grid is null; the bytes stay valid because reduced motion pins normMax to d.max. This also keeps the texture rebuildable client-side (§27) at a quarter of the old f32 grid's footprint. --- js/src/45_lod.ts | 6 ++++-- js/src/54_kernel.ts | 11 +++++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/js/src/45_lod.ts b/js/src/45_lod.ts index 4eef054b..faa70316 100644 --- a/js/src/45_lod.ts +++ b/js/src/45_lod.ts @@ -140,7 +140,7 @@ function lodUploadGridBytes(gl, tex, data, w, h, isRgba, filter) { gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); } -function lodUploadWireGrid(gl, u8Wire, w, h, filter) { +export function lodUploadWireGrid(gl, u8Wire, w, h, filter) { const tex = gl.createTexture(); lodUploadGridBytes(gl, tex, u8Wire, w, h, false, filter); return tex; @@ -1237,6 +1237,7 @@ export function lodApplyDensityUpdate(view, g, upd, buffers) { const reducedMotion = view._prefersReducedMotion(); const normMax = reducedMotion ? d.max : normStart; const directWire = d.enc === "log-u8" && !rgba && reducedMotion; + const wire = directWire ? new Uint8Array(view._asU8(buffers[d.buf])) : null; const grid = directWire ? null : d.enc === "log-u8" @@ -1255,11 +1256,12 @@ export function lodApplyDensityUpdate(view, g, upd, buffers) { // enough to points territory to be worth a round-trip at all. visible: upd.visible, grid, + wire, rgba, filter, _filterKey: replyFilterKey, tex: directWire - ? lodUploadWireGrid(view.gl, view._asU8(buffers[d.buf]), d.w, d.h, filter) + ? lodUploadWireGrid(view.gl, wire, d.w, d.h, filter) : view._uploadGrid( grid, d.w, d.h, normMax, rgba, filter, view._fillOpacity(g.trace.style), ), diff --git a/js/src/54_kernel.ts b/js/src/54_kernel.ts index 64b1075f..c298ef83 100644 --- a/js/src/54_kernel.ts +++ b/js/src/54_kernel.ts @@ -4,6 +4,7 @@ import { parseColor } from "./20_theme"; import { lodAggregateStands, lodAggregateStepWindow, lodApplyDensityUpdate, lodApplyDrill, lodDrillServesView, lodDropDrill, lodFilterKey, lodPromoteCachedDrill, lodRememberDensity, + lodUploadWireGrid, } from "./45_lod"; import { xyCreateRebinWorker } from "./46_worker"; import { ChartView } from "./50_chartview"; @@ -280,10 +281,12 @@ Object.assign(ChartView.prototype, { const hd = g._homeDensity; this._applySampleRebinGrid(g, { ...hd, - tex: this._uploadGrid( - hd.grid, hd.w, hd.h, hd.normMax || hd.max || 1, hd.rgba, hd.filter, - this._fillOpacity(g.trace.style), - ), + tex: hd.grid + ? this._uploadGrid( + hd.grid, hd.w, hd.h, hd.normMax || hd.max || 1, hd.rgba, hd.filter, + this._fillOpacity(g.trace.style), + ) + : lodUploadWireGrid(this.gl, hd.wire, hd.w, hd.h, hd.filter), }, false); } return; From 3b9bbd1e824912d8bba9a454d275c1b491089050 Mon Sep 17 00:00:00 2001 From: Soumya Snigdha Kundu Date: Tue, 28 Jul 2026 21:08:53 +0100 Subject: [PATCH 3/5] Cover direct-wire density and its home restore in the render smoke Reduced-motion count-only log-u8 replies had no coverage: pytest, all three smokes, and the upload-hash check all passed with a null-grid dereference in the home-extent restore. The new probes force reduced motion, assert the wire bytes upload verbatim and stay retained on density.wire, and drive _requestSampleRebin's home restore to prove it re-uploads from the retained copy instead of throwing. Both fail against the pre-fix client. --- scripts/render_smoke_nonumpy.py | 43 ++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/scripts/render_smoke_nonumpy.py b/scripts/render_smoke_nonumpy.py index 24aedf26..559d745b 100644 --- a/scripts/render_smoke_nonumpy.py +++ b/scripts/render_smoke_nonumpy.py @@ -790,6 +790,35 @@ def main() -> None: if(c>0&&Math.abs(qg[i]-c)/c>0.08)qok=0;}} }} const qwire=(qok && Math.abs(gd.density.max-qmax)<1e-9)?1:0; + // --- Direct-wire density (reduced motion): a count-only log-u8 reply + // uploads the wire bytes verbatim (grid null, normMax pinned to max) and + // retains a copy on density.wire so re-uploads survive without a decoded + // grid. Then the home-extent restore in _requestSampleRebin — the path + // that dereferenced the null grid — must re-upload from the wire copy. + const rmPrev=v._prefersReducedMotion.bind(v); + v._prefersReducedMotion=()=>true; + const rmax=9, renc=new Uint8Array(64); + for(let i=0;i<64;i++){{const c=(i%4===0)?9:(i%7===0?1:0); + renc[i]=c>0?Math.max(1,Math.min(255,Math.round(255*Math.log1p(c)/Math.log1p(rmax)))):0;}} + v._onKernelMsg({{type:"density_update",traces:[{{id:gd.trace.id,mode:"density",visible:12345, + density:{{buf:0,w:8,h:8,max:rmax,enc:"log-u8",x_range:[0,100],y_range:[0,100]}}}}]}},[renc.buffer.slice(0)]); + let dwire=(gd.density.grid===null && gd.density.wire instanceof Uint8Array && + gd.density.wire.length===64 && gd.density.normMax===rmax && + v.gl.isTexture(gd.density.tex))?1:0; + if(dwire){{for(let i=0;i<64;i++) if(gd.density.wire[i]!==renc[i]) dwire=0;}} + let drestore=0; + try{{ + gd._homeDensity=gd.density; + v._onKernelMsg({{type:"density_update",traces:[{{id:gd.trace.id,mode:"density",visible:2345, + density:{{buf:0,w:8,h:8,max:rmax,enc:"log-u8",x_range:[40,60],y_range:[40,60]}}}}]}},[renc.buffer.slice(0)]); + const zoomed=gd.density!==gd._homeDensity; + if(!v.view0) v.view0={{...v.view}}; + v._requestSampleRebin(gd, v.view0, 0); + drestore=(zoomed && gd.density.grid===null && gd.density.wire instanceof Uint8Array && + gd.density.xRange[1]===100 && v.gl.isTexture(gd.density.tex))?1:0; + }}catch(e){{ drestore=0; }} + gd._homeDensity=null; + v._prefersReducedMotion=rmPrev; // --- Rapid zoom in/out torture (drill thrash): the marks/density alphas // must be CONTINUOUS across window-boundary crossings, dying-drill // revives, and kernel replies landing mid-transition. Runs on a virtual @@ -1169,7 +1198,7 @@ def main() -> None: gMc.readPixels(Math.round(WM*0.8),Math.round(HM*0.5),1,1,gMc.RGBA,gMc.UNSIGNED_BYTE,rpx); const meancolor=(lpx[0]>60 && lpx[0]>lpx[2]*3 && rpx[2]>60 && rpx[2]>rpx[0]*3)?1:0; vMc.destroy();holderMc.remove(); - const base=`XY_OK lit=${{lit}} total=${{w*h}} labels=${{labels}} pick=${{hits}} row=${{hasXY}} selAll=${{selAll}} selSome=${{selSome}} active=${{active}} btns=${{btns}} modebarHidden=${{modebarHiddenAtRest}} modebarTopLeft=${{modebarTopLeft}} modebarHover=${{modebarHoverReveal}} modebarNoCollapse=${{modebarNoCollapse}} modebarMenu=${{modebarMenu}} modebarDrag=${{modebarDrag}} modebarSelect=${{modebarSelect}} lassoEdit=${{lassoEdit}} modebarExport=${{modebarExport}} panToggle=${{panToggle}} zin=${{zin}} smooth=${{smooth}} labelThrottle=${{labelThrottle}} hoverSkip=${{hoverSkip}} zanch=${{zanch}} retarget=${{retarget}} nosnap=${{nosnap}} prefetch=${{prefetch}} maxwait=${{maxwait}} box=${{boxOk}} xonly=${{xonly}} zmode=${{zmode}} densityLit=${{densityLit}} drill=${{drilled}} pending=${{pending}} dblend=${{dblend}} dseq=${{dseq}} hov=${{hov}} sstale=${{sstale}} sfresh=${{sfresh}} srestore=${{srestore}} plut=${{plut}} reg=${{reg}} refresh=${{refresh}} dpick=${{dpick}} hold=${{hold}} zoomout=${{zoomout}} broad=${{broadfallback}} dying=${{dying}} dback=${{dback}} dnorm=${{dnorm}} dnormDone=${{dnormDone}} stale=${{stale}} thrash=${{thrash}} qwire=${{qwire}} stream=${{stream}} tj=${{Math.round(maxJump*100)}} td=${{Math.round(reviveDip*100)}} malformed=${{malformed}} pixdet=${{pixdet}} splitbuf=${{splitbuf}} barBase=${{barBase}} histBase=${{histBase}} edgepad=${{edgepad}} mgrad=${{mgrad}} axisontop=${{axisontop}} mtipbase=${{mtipbase}} mcorner=${{mcorner}} mstroke=${{mstroke}} bgrad=${{bgrad}} bcorner=${{bcorner}} msmooth=${{msmooth}} bgocc=${{bgocc}} meancolor=${{meancolor}} dretire=${{dretire}}`; + const base=`XY_OK lit=${{lit}} total=${{w*h}} labels=${{labels}} pick=${{hits}} row=${{hasXY}} selAll=${{selAll}} selSome=${{selSome}} active=${{active}} btns=${{btns}} modebarHidden=${{modebarHiddenAtRest}} modebarTopLeft=${{modebarTopLeft}} modebarHover=${{modebarHoverReveal}} modebarNoCollapse=${{modebarNoCollapse}} modebarMenu=${{modebarMenu}} modebarDrag=${{modebarDrag}} modebarSelect=${{modebarSelect}} lassoEdit=${{lassoEdit}} modebarExport=${{modebarExport}} panToggle=${{panToggle}} zin=${{zin}} smooth=${{smooth}} labelThrottle=${{labelThrottle}} hoverSkip=${{hoverSkip}} zanch=${{zanch}} retarget=${{retarget}} nosnap=${{nosnap}} prefetch=${{prefetch}} maxwait=${{maxwait}} box=${{boxOk}} xonly=${{xonly}} zmode=${{zmode}} densityLit=${{densityLit}} drill=${{drilled}} pending=${{pending}} dblend=${{dblend}} dseq=${{dseq}} hov=${{hov}} sstale=${{sstale}} sfresh=${{sfresh}} srestore=${{srestore}} plut=${{plut}} reg=${{reg}} refresh=${{refresh}} dpick=${{dpick}} hold=${{hold}} zoomout=${{zoomout}} broad=${{broadfallback}} dying=${{dying}} dback=${{dback}} dnorm=${{dnorm}} dnormDone=${{dnormDone}} stale=${{stale}} thrash=${{thrash}} qwire=${{qwire}} dwire=${{dwire}} drestore=${{drestore}} stream=${{stream}} tj=${{Math.round(maxJump*100)}} td=${{Math.round(reviveDip*100)}} malformed=${{malformed}} pixdet=${{pixdet}} splitbuf=${{splitbuf}} barBase=${{barBase}} histBase=${{histBase}} edgepad=${{edgepad}} mgrad=${{mgrad}} axisontop=${{axisontop}} mtipbase=${{mtipbase}} mcorner=${{mcorner}} mstroke=${{mstroke}} bgrad=${{bgrad}} bcorner=${{bcorner}} msmooth=${{msmooth}} bgocc=${{bgocc}} meancolor=${{meancolor}} dretire=${{dretire}}`; const baseWithStyle=`${{base}} vstyle=${{vstyle}}`; // Responsive: 100%-by-100% chart in a 400x300 container tracks its parent; // growing the container must fire the ResizeObserver and re-render bigger. @@ -1398,6 +1427,8 @@ def main() -> None: stale = int(re.search(r"stale=(\d+)", title).group(1)) thrash = int(re.search(r"thrash=(\d+)", title).group(1)) qwire = int(re.search(r"qwire=(\d+)", title).group(1)) + dwire = int(re.search(r"dwire=(\d+)", title).group(1)) + drestore = int(re.search(r"drestore=(\d+)", title).group(1)) stream = int(re.search(r"stream=(\d+)", title).group(1)) malformed = int(re.search(r"malformed=(\d+)", title).group(1)) pixdet = int(re.search(r"pixdet=(\d+)", title).group(1)) @@ -1564,6 +1595,16 @@ def main() -> None: ) if qwire != 1: raise SystemExit("log-u8 density decode failed (quantized wire)") + if dwire != 1: + raise SystemExit( + "direct-wire density failed (reduced motion must upload the " + "log-u8 wire bytes verbatim and retain them on density.wire)" + ) + if drestore != 1: + raise SystemExit( + "direct-wire home restore failed (null-grid density must " + "re-upload from the retained wire bytes, not throw)" + ) if meancolor != 1: raise SystemExit( "mean-color density failed (surface must wear the per-cell mean " From c580e3778e8665e9d42d70b9b743bffb31911e5b Mon Sep 17 00:00:00 2001 From: Soumya Snigdha Kundu Date: Tue, 28 Jul 2026 21:09:45 +0100 Subject: [PATCH 4/5] Correct the rgba copy comment to name its real readers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exposure easing never re-reads the color plane: rgba densities null their norm anim (T4 is count-only), so lodStepNorm's rgba read is unreachable for them. The copy is still required — legend-hover dimming and the home-extent restore re-read it after the wire buffer is released. --- js/src/45_lod.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/js/src/45_lod.ts b/js/src/45_lod.ts index faa70316..9516e2b5 100644 --- a/js/src/45_lod.ts +++ b/js/src/45_lod.ts @@ -1230,8 +1230,8 @@ export function lodApplyDensityUpdate(view, g, upd, buffers) { } } } - // Mean point color plane (LOD doc §2), copied because exposure easing - // re-reads it on every norm step, after the wire buffer may be gone. + // Mean point color plane (LOD doc §2), copied because legend-hover dimming + // and home-restore re-uploads re-read it after the wire buffer may be gone. const rgba = d.rgba !== undefined ? new Uint8Array(view._asU8(buffers[d.rgba])) : null; const normStart = lodNormMax(g, d.max); const reducedMotion = view._prefersReducedMotion(); From a271f757ad9162652af8bbc6556c53a306b2cdb8 Mon Sep 17 00:00:00 2001 From: Soumya Snigdha Kundu Date: Tue, 28 Jul 2026 21:38:07 +0100 Subject: [PATCH 5/5] Record the direct-wire density upload under the T4 easing invariant --- spec/design/lod-architecture.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/spec/design/lod-architecture.md b/spec/design/lod-architecture.md index 7deebdf7..4572bdd9 100644 --- a/spec/design/lod-architecture.md +++ b/spec/design/lod-architecture.md @@ -376,6 +376,13 @@ invariants so future kinds don't regress them: - **T4 — normalization is eased, never stepped** (exposure-style normMax) — count-only surfaces; a mean-color texture's physical alpha is max-independent, so it has no normalization to ease. + When there is nothing to ease, the wire bytes ARE the texture: a + count-only `log-u8` reply under `prefers-reduced-motion` pins normMax at + the reply's own `max`, so decoding to floats and re-encoding against that + same max is the identity. Those replies upload `buf` straight to R8 + (`lodUploadWireGrid`) and keep `grid` null, retaining the wire bytes so the + `_homeDensity` restore (T7) can re-upload after the buffer is gone. Every + other reply still decodes through `_uploadGrid`. - **T5 — stale replies die:** seq on view updates, drill_seq on subsets, pending-view hold for prefetched drills. - **T6 — invalid requests do not mutate:** malformed viewport/screen requests