4,915 words · 1 diagram
Five questions that needed a measurement rather than an opinion. Each blocked something specific, each was small, and each is recorded here with the numbers that came back.
Roadmap is what is not built yet. This was what could not be planned yet, because the answer was unknown and guessing it would have put a number in a design document that nobody measured.
All five have been run. Three came back clean, one came back with a result that reverses a design assumption, and one is answered as far as software can answer it and is now waiting on hardware.
Status
| # | Spike | Blocks | Status | The number that mattered |
|---|---|---|---|---|
| 1 | HDR display detection | M2 | ✅ Resolved — negative | Three browsers, one display, (dynamic-range: high) = true, true, false |
| 2 | TSL and the atmosphere integral | M2 | ✅ Resolved — positive | 1.000× against hand-written WGSL, pixel-identical |
| 3 | Catalog bundle size | M4 | ✅ Resolved — positive | 150 ly, 7,529 stars + 861 planets = 159 KB brotli |
| 4 | Gaia and HYG attribution terms | M4 | ⚠️ Resolved — reverses a decision | Gaia is CC BY-NC 3.0 IGO, not "open with attribution" |
| 5 | WebHID and Gamepad for HOTAS | M3 | 🟡 Software answered, hardware outstanding | WebHID: Chrome ✅, Safari ❌, Firefox ❌ · Gamepad caps at 16 axes / 32 buttons |
Measured on 2026-08-19 on an Apple M5 MacBook (10-core GPU, Metal 3, built-in 2880×1864 Liquid Retina — not an XDR display), macOS 26.5, against Chrome 151.0.7922.138, Safari 26.5.2 and Firefox 153.0. Where a number depends on the machine, that is said at the point of use.
flowchart LR
S5["<b>5 · HOTAS</b><br/>🟡 hardware left"] --> S2["<b>2 · TSL</b><br/>✅ free"]
S2 --> S1["<b>1 · HDR detect</b><br/>✅ unreliable"]
S1 --> S3["<b>3 · Bundle size</b><br/>✅ cheap"]
S3 --> S4["<b>4 · Data terms</b><br/>⚠️ Gaia is NC"]
style S4 fill:#7f1d1d,stroke:#450a0a,color:#fff
style S5 fill:#78350f,stroke:#451a03,color:#fff
style S2 fill:#14532d,stroke:#052e16,color:#fff
style S1 fill:#14532d,stroke:#052e16,color:#fff
style S3 fill:#14532d,stroke:#052e16,color:#fffHow to run one
Every spike below had the same shape, and the shape matters more than the individual answers:
- Read the context. Start with AGENTS.md, then the page each spike links. Do not start by reading the whole codebase.
- Build the smallest thing that answers the question. A spike is throwaway.
It does not need tests, it does not need to be pretty, and it does not go
through
pnpm check. Put it in a scratch directory and expect to delete it. - Write down the number, not the impression. "It works" is not a result. "7.274 ms p50 over 60 passes at 1920×1080 on an M5" is.
- Record the answer where the guess currently lives — the
[OPEN QUESTION]tag named in each spike. Replace the tag with the finding. - If the answer changes a design decision, say so. Spike 4 came back negative and it is worth more than the three that came back positive.
- Prove the two things you are comparing are the same thing. Spike 2's first result said TSL was 2× slower. It was measuring two different workloads through an instrument that was lying. See the trap.
Do not land production code from a spike. The point is to buy information, and information bought is cheaper than code kept.
1 · HDR display detection
| Blocks | M2 |
| Question | Can the page reliably tell whether it is on an extended-range display, and can the user override it in both directions? |
| Answer | No, it cannot tell. Yes, it can override. |
| Lives in | design/art.md § HDR |
What came back
The same physical display, the same second, three browsers:
| Signal | Chrome 151 | Safari 26.5 | Firefox 153 |
|---|---|---|---|
(dynamic-range: high) |
true | true | false |
(dynamic-range: standard) |
true | true | true |
(video-dynamic-range: high) |
false | false | true |
(video-dynamic-range: standard) |
false | false | true |
(color-gamut: p3) |
true | true | false |
screen.isExtended |
false | absent | absent |
screen.colorDepth |
30 | 24 | 30 |
screen.highDynamicRangeHeadroom |
absent | absent | absent |
dynamic-range-limit: standard | no-limit |
✅ | ✅ | ❌ |
dynamic-range-limit: constrained |
✅ | ❌ | ❌ |
computed initial dynamic-range-limit |
no-limit |
no-limit |
— |
WebGPU rgba16float canvas, toneMapping: extended |
accepted | accepted | throws |
getConfiguration().toneMapping echoes back |
{"mode":"extended"} |
{"mode":"extended"} |
— |
vec4f(8,4,2,1) survives the swap chain |
8, 4, 2, 1 |
8, 4, 2, 1 |
— |
WebGL2 drawingBufferStorage |
function | absent | absent |
Firefox's failure is explicit and tracked: "GPUCanvasContext.configure: Canvas
texture format rgba16float is not yet supported. Subscribe to
https://bugzilla.mozilla.org/show_bug.cgi?id=1834395".
Why (dynamic-range: high) is not the signal it looks like
The display under test is an ordinary laptop panel — macOS reports it as
Color LCD, 8 bits per sample, with no reference HDR mode
(maximumReferenceExtendedDynamicRangeColorComponentValue = 0.0). What it does
have is EDR headroom:
screen: Built-in Retina Display
maximumEDRColorComponentValue = 2.0
maximumPotentialEDRColorComponentValue = 2.0
maximumReferenceEDRColorComponentValue = 0.02× headroom on a panel nobody would call an HDR display. Chrome and Safari
report (dynamic-range: high) for it, and they are not wrong — the compositor
really will accept values above 1.0. They are answering "will extended range be
carried?", not "is this a display worth authoring HDR for". Those are different
questions and only the first one has an API.
The rest of the signals are worse:
(dynamic-range: standard)matches everywhere, by specification. It is not the negation ofhighand cannot be used as one.video-dynamic-rangeis unimplemented in Chrome and Safari (both sub-queries false) and reports both true in Firefox. Unusable in either direction.screen.isExtendeddescribes multi-monitor topology, not dynamic range. It wasfalseon a single-display machine that reportsdynamic-range: high.- There is no headroom API. The page cannot distinguish 2× from an XDR display's ~16×, so it cannot tune a tone curve to the display it is on.
The recommendation
// Capability, not display quality. The media query says the compositor will
// carry extended range; the WebGPU configure says this browser can produce it.
// Neither says the panel is worth it, and nothing available says how much
// headroom there is — so the curve must not assume a number.
const canOutputExtendedRange =
'gpu' in navigator &&
window.matchMedia('(dynamic-range: high)').matches &&
(await probeExtendedCanvas()) // configure rgba16float + toneMapping:'extended'probeExtendedCanvas()is the load-bearing half. It is what excludes Firefox, and it is a feature test rather than a guess about hardware.dynamic-range-limit's initial value is alreadyno-limit, so nothing is needed to opt in;standardis the opt-out lever and it inherits, which makes "clamp everything under this subtree" a one-line CSS change.- The media query is live — attach a
changelistener rather than reading once, because a window can move between displays. - The three-state setting was already mandatory and remains so. Auto is now defined as the expression above.
Correction to what the bible said
ExtendedSRGBColorSpace is not a three core export. It lives in
three/examples/jsm/math/ColorSpaces.js, and in r182 its
outputColorSpaceConfig.toneMappingMode is dead weight —
ColorManagement.getToneMappingMode() has no caller anywhere in src/. The
WebGPU backend derives everything from one constructor parameter:
// WebGPUUtils.getPreferredCanvasFormat() → GPUTextureFormat.RGBA16Float
// WebGPUBackend, line 260 → toneMapping: { mode: 'extended' }
new THREE.WebGPURenderer({ outputType: THREE.HalfFloatType })Setting renderer.outputColorSpace alone does not turn on extended output.
outputType: HalfFloatType does, and it sets both the canvas format and the tone
mapping mode together.
Still unmeasured
Needs hardware this project does not have: an XDR or true-HDR display, Windows
with HDR on and off, a Linux/Chrome baseline, whether the media query updates when
a window is dragged between displays, and whether live EDR headroom changes with
screen brightness produce a change event. None of these change the
recommendation — they would only change how often auto is right, and the
manual override exists because it will sometimes be wrong.
2 · TSL and the atmosphere integral
| Blocks | M2 |
| Question | Does Three.js's shading language cost anything material on a dense atmosphere integral, or is a hand-written WebGPU pass needed? |
| Answer | It costs nothing measurable. Take TSL and stop thinking about it. |
| Lives in | design/technical.md § The WebGPU migration |
Method
The same single-scattering Rayleigh + Mie + ozone raymarch written twice — once in
TSL, once by hand in WGSL — then both run as raw WebGPU pipelines in one
harness: same rgba16float 1920×1080 target, same fullscreen triangle, same
uniforms, GPU time from timestamp-query, A and B interleaved so clock ramping
hits both equally, 60 passes each after 20 warm-up passes.
The TSL side is not the TSL renderer — it is the WGSL that three's node system
generates, harvested with renderer.debug.getShaderAsync() and then run through
the identical harness. That isolates the code generator, which is what the
question is about.
Outputs verified pixel-identical before any timing was believed.
What came back
32 view × 8 light samples, 1920×1080, Apple M5:
| Case | Hand-written WGSL | TSL-generated WGSL | Ratio |
|---|---|---|---|
| Orbit, 400 km | p50 0.393 ms · p95 0.459 | p50 0.393 ms · p95 0.459 | 1.000× |
| High, 60 km | p50 7.274 ms · p95 7.340 | p50 7.274 ms · p95 7.340 | 1.000× |
| Ground, 2 m | p50 7.274 ms · p95 7.340 | p50 7.274 ms · p95 7.274 | 1.000× |
| Hand-written | TSL-generated | |
|---|---|---|
| Source size | 3,859 B · 121 lines | 5,196 B · 188 lines (+35%) |
| Pipeline build, median of 6, cache defeated | 1.00 ms | 0.90 ms |
The 15% threshold the spike set for "take TSL and stop thinking about it" was met by a factor of ten. Chrome's timestamp results are quantized to ~65.5 µs on this machine, so the orbit case is at the resolution floor — the two are indistinguishable there rather than merely close.
What the generated code looks like
Readable, and structurally different from hand-written WGSL in exactly one way that matters:
- Everything is inlined. No function calls survive; each TSL
Fnis expanded at each call site, including inside both loop nests. - Every intermediate becomes a function-scope
var— 37 of them, hoisted to the top — where a person writeslet. This is the one thing that could have cost register pressure, and on Metal it does not: Tint and the Metal compiler promote them. - Constants are not folded (
(0.8 * 0.8)survives into the output); the backend compiler folds them. - The file opens with
diagnostic(off, derivative_uniformity), and WGSL requires directives before all global declarations — so generated code cannot simply be concatenated after anything else.
The trap that nearly produced the wrong answer
The first attempt measured the TSL path through three's renderer using
renderer.info.render.timestamp and got 14.615 ms against the hand-written
7.274 ms — a clean, plausible, completely false 2×.
renderer.info.render.timestamp over-reports when there is a canvas output pass.
Rendering the identical material to an offscreen target reported 7.274 ms; the
same frame to the canvas reported 14.615 ms. Independent wall-clock, measured by
submitting 40 frames and awaiting queue.onSubmittedWorkDone():
| Same workload, same material | To canvas | To offscreen target |
|---|---|---|
| Wall clock, queue-drained | 7.375 ms/frame | 7.265 ms/frame |
renderer.info.render.timestamp |
14.615 ms | 7.274 ms |
The canvas output pass really costs 0.11 ms (1.5%). The instrument was double-counting. Two lessons, both cheap to carry forward:
renderer.info.render.timestampis not trustworthy on the canvas path. Use wall clock across a drained queue, or a raw timestamp query.- A 2× result that is exactly 2× deserves suspicion before it deserves a design change.
The consequence nobody asked for
7.274 ms for 256 samples per pixel at 1080p on an M5 is already 2.4× over the 3.0 ms atmosphere-and-post budget, on a GPU far faster than the target machine. Scaling to fit gives about 105 samples per pixel — roughly 16 view × 6 light — which is not enough for a clean horizon.
So Bruneton's precomputed transmittance and multiple-scattering LUTs are not an optimization, they are a requirement. The spike was written to ask whether TSL could express the integral cheaply enough. It can; the integral itself cannot be evaluated per-pixel per-frame at any language's speed.
Still unmeasured
Full Bruneton multiple scattering with LUT precomputation, the precompute cost and whether it amortizes across frames, register pressure (no tooling exposes it), and behavior on the 2023-class integrated GPU that is the actual target. The ratio is what transfers between machines; the absolute milliseconds do not.
3 · Catalog bundle size
| Blocks | M4 |
| Question | What does a packed catalog sphere actually cost over the wire, at 25 ly and at 150 ly? |
| Answer | 159 KB brotli for everything out to 150 ly. Bundle it all. |
| Lives in | design/galaxy.md § Ingest pipeline |
Source
HYG v4.4, hyg/CURRENT/hyg_v44.csv.gz — 119,614 rows, of which 109,390 have
a usable parallax (10,224 carry HYG's dist >= 100000 "no usable parallax"
sentinel and must be dropped, not clamped).
The dataset moved. HYG is now at codeberg.org/astronexus/hyg; the GitHub repository is frozen and its newest file is v4.1. The files are git-lfs pointers, so a plain
raw/fetch returns a 133-byte pointer file — use Codeberg'smedia/path. Both facts cost time to discover; the ingest pipeline should pin the source URL and assert on the decompressed row count.
Reproduced by the ingest, 20 Aug 2026. Every row count below came back identical from the real pipeline. The shipped record is not the 16-byte one sketched here: it carries a stable id and the designations a star is known by, which the spike did not price, and it drops proper motion, which the spike did not need either. 458 KB packed, 179 KB brotli for 7,123 systems and 702 planets — 25.8 bytes per system against the 16 estimated here, for names that turned out to be the difference between "HIP71683" and "Alpha Centauri" on the HUD. See
design/galaxy.mdandguides/catalog.md.
The packed record
16 bytes per star, which is the whole answer to "what does it cost":
| Bytes | Field | Note |
|---|---|---|
| 0–8 | position, 3 × int24 | galactic cartesian, quantized against the chunk extent |
| 9 | spectral class | class × subclass × giant flag, one byte |
| 10–11 | absolute magnitude, int16 ×100 | luminosity is 10^((4.85 − M)/2.5) — do not store it as well |
| 12–13 | color index B−V, int16 ×1000 | -32768 is "unknown"; drives the render color |
| 14 | flags | component count, has-name, provenance |
| 15 | reserved |
Plus an 8-byte identity row per star (HYG id + HIP) and a designation table for the stars that have one. Position resolution at 150 ly is 1.13 AU per step, worst observed quantization error 0.94 AU — four orders of magnitude below the parallax uncertainty at that distance, so the quantizer is free.
What came back
| Radius | Stars | HYG rows as JSON | …brotli | Packed | …brotli | + ids + names, brotli |
|---|---|---|---|---|---|---|
| 25 ly | 166 | 92.4 KB | 18.7 KB | 2.6 KB | 2.4 KB | 4.1 KB |
| 50 ly | 978 | 541.8 KB | 103.6 KB | 15.3 KB | 12.9 KB | 21.1 KB |
| 100 ly | 4,049 | 2.18 MB | 417.2 KB | 63.3 KB | 52.3 KB | 81.9 KB |
| 150 ly | 7,529 | 4.04 MB | 769.0 KB | 117.6 KB | 97.0 KB | 143.6 KB |
Columnar beats interleaved by 7–8% after brotli — the same fields laid out structure-of-arrays compress better because like values sit together. Free, so take it.
Confirmed planets, live from the NASA Exoplanet Archive TAP service on 2026-08-19:
| Radius | Host systems | Planets |
|---|---|---|
| 25 ly | 39 | 84 |
| 50 ly | 120 | 216 |
| 100 ly | 314 | 520 |
| 150 ly | 550 | 861 |
At 20 bytes per planet plus names, the whole planet layer to 150 ly is 15.2 KB brotli.
Against the app it ships beside
Measured from pnpm build on the same day:
| Raw | gzip | brotli | |
|---|---|---|---|
index.js |
1,155,149 B | 324.6 KB | 249.3 KB |
index.css |
15,554 B | 3.9 KB | 3.4 KB |
universe.worker.js |
20,127 B | 8.1 KB | 7.3 KB |
| Total client | 1.19 MB | ~337 KB | ~260 KB |
| 150 ly catalog | 274 KB | — | ~159 KB |
The full local tier is 61% of the current client and lands the whole download around 420 KB. That is not a conversation, it is a rounding error against the 4-second cold-load budget. Bundle everything to 150 ly.
Chunking, for when it does become streamed
Cell-local coordinates (rebasing to the cell origin before quantization — the first attempt did not, clamped every value, and produced a negative chunking penalty, which is what a bug looks like):
| Cell | Non-empty cells | Summed brotli | Largest cell | Median cell | vs one blob |
|---|---|---|---|---|---|
| 10 ly | 5,132 | 137.7 KB | 0.17 KB | 0.02 KB | +47% |
| 25 ly | 1,030 | 118.5 KB | 0.61 KB | 0.08 KB | +26% |
| 50 ly | 173 | 105.1 KB | 3.33 KB | 0.46 KB | +12% |
| 75 ly | 62 | 99.9 KB | 6.82 KB | 1.39 KB | +7% |
Compression wants big chunks and request count wants few of them, and they agree: 50 ly cells. 25 ly cells cost 26% more bytes across 1,030 requests to save nothing.
The finding that actually matters
Size was never the constraint. Completeness is.
| Volume | HYG entries | Best available census | HYG coverage |
|---|---|---|---|
| 10 pc (32.6 ly) | 324 | 462 objects in 317 systems — RECONS, 2018.3 | ~70% |
| 25 pc (81.5 ly) | 3,072 | 5,931 objects — CNS5, Golovin et al. 2023 | ~52% |
CNS5's 5,931 is 5,230 stars plus 701 brown dwarfs, so HYG holds roughly 59% of the known stars within 25 pc and none of the brown dwarfs. And its character changes with distance — the fraction of entries carrying a Gliese (nearby-star) identifier falls from 95% at 50 ly to 80% at 100 ly to 47% at 150 ly, with the apparent-magnitude histogram peaking at V≈8. Inside ~50 ly HYG is a volume-complete catalog; by 150 ly it is a magnitude-limited one wearing the same shape.
That does not break the design — it sharpens it. The horizon of knowledge is already a first-class idea, and this says the boundary is much closer than 150 ly for M dwarfs and much further for bright stars. The shell is not a sphere, and drawing it as one would be a lie the design has already promised not to tell.
One ingest gotcha, found by parsing the file
HYG spectral types are not uniform MK strings. Within 150 ly: 610 entries have
none, 163 are white dwarfs (DA2, DZ…), and around 200 use Yale-style
prefixes (dM4, sdK7, gK5) that a spect[0] test mislabels. A naive
first-character parse classifies 6,551 of 7,529 — 87%, quietly wrong about the
other 13%. Strip the prefix, handle D… as its own class.
✅ Done, with a golden vector per shape in
packages/universe/src/catalog/catalog.test.ts. The built parser leaves 2 of
7,123 systems unread. The spike missed one thing worth adding: 571 entries are
the single lowercase letter m, which is neither a prefix nor a class and is the
largest single group after "no spectral type at all".
4 · Gaia and HYG attribution terms
| Blocks | M4 |
| Question | What exactly do the licenses require, and where must the attribution appear? |
| Answer | Gaia is CC BY-NC 3.0 IGO. That is not what the bible assumed and it changes the ingest plan. |
| Lives in | design/sustainability.md § Data licensing |
The finding
The bible recorded Gaia as "Open with attribution". ESA's own license page says otherwise, verbatim:
Gaia data are distributed under the CC BY-NC 3.0 IGO license. — cosmos.esa.int/web/gaia-users/license
and the archive terms it points at:
Data hosted in the ESA Space Science Archives are distributed under the CC BY-NC 3.0 IGO license.
Prior to any commercial use by the User of any Data or Data Product, including any use or application that directly or indirectly generates a financial gain, a detailed request for authorization/license shall be made by the User by sending email to data.licenses@esa.int. — ESA Space Science Archives terms and conditions
Non-commercial. Sustainability argues at length that a non-commercial clause is not an open source license and that the project deliberately avoided one. Bundling Gaia would attach exactly that restriction to the shipped artifact — not to the Apache-2.0 code, but to the data the game cannot run without.
There is a conflicting statement in Gaia's own DR3 documentation — "The Gaia data are open and free to use, provided credit is given to 'ESA/Gaia/DPAC'" — which reads much more permissively than the license page. A documentation page and a license page that disagree are a reason to take the stricter one, and to ask ESA in writing before relying on the looser one.
The strings, verbatim
| Where | What must appear |
|---|---|
| Gaia — credit line | Credit: ESA, Gaia DPAC |
| Gaia — full acknowledgment | "This work has made use of data from the European Space Agency (ESA) mission Gaia (https://www.cosmos.esa.int/gaia), processed by the Gaia Data Processing and Analysis Consortium (DPAC, https://www.cosmos.esa.int/web/gaia/dpac/consortium). Funding for the DPAC has been provided by national institutions, in particular the institutions participating in the Gaia Multilateral Agreement." |
| HYG | CC BY-SA 4.0 (v4.x; v3.x was CC BY-SA 2.5). Credit The HYG Database, astronexus, link the license, link the source, and state that it was modified. |
| NASA Exoplanet Archive | "This research has made use of the NASA Exoplanet Archive, which is operated by the California Institute of Technology, under contract with the National Aeronautics and Space Administration under the Exoplanet Exploration Program." Cite Christiansen et al. (2025). |
Does share-alike reach the packed binary? Yes. Does it reach the code? No.
CC BY-SA 4.0 § 4(b), verbatim:
if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material, including for purposes of Section 3(b)
So the packed catalog from spike 3 is Adapted
Material and must carry CC BY-SA 4.0. The emphasis is the load-bearing part: the
obligation attaches to the database, not to everything that touches it. Apache-2.0
on packages/* and CC BY-SA 4.0 on the catalog coexist without conflict,
because they cover different works.
That has a concrete engineering consequence. The packed catalog must ship as
its own asset with its own license notice beside it — not inlined into the JS
bundle as a literal, which would blur exactly the boundary the licenses depend on.
A separate .bin fetched at runtime is an aggregation; a base64 blob compiled into
index.js invites the argument that it is not.
§ 3(a)(1) sets what the notice has to contain: creator identification, a copyright notice, a notice referring to the license, a notice referring to the warranty disclaimer, a URI to the source, an indication that it was modified — satisfiable "in any reasonable manner based on the medium".
NASA is not confirmed public domain
The bible recorded the archive as "US Government work, public domain". The archive's own pages state no license at all. It is operated by Caltech under NASA contract, and its values are drawn from the published literature. The defensible position: the individual measurements are facts and facts are not copyrightable in the US, while the compilation may attract database right in the EU. Treat the requested acknowledgment as the obligation, use it, and stop describing the data as public domain in project documentation.
Recommendation
- Ship HYG + NASA. Keep Gaia out of the bundle until ESA answers a written
request at
data.licenses@esa.int. HYG's CC BY-SA is share-alike, which is an obligation; Gaia's CC BY-NC is a restriction, which is a different kind of problem. - Beware AT-HYG. The Gaia-derived HYG-like set is published as CC BY-SA 4.0 but is built on Gaia DR3 data. Do not adopt it without resolving that.
NOTICEbecomes required at the first ingest, and the license text plus all three attribution strings go in it in the same change that first reads a dataset.- In-product attribution on the catalog panel, as the design already wanted. Obligation and design goal agree, which is rare enough to bank.
The stated fallback was backwards
The spike's fallback read: "if HYG's share-alike proves awkward, rebuild the ingest from Gaia and the NASA archive directly." That is strictly worse. It trades a share-alike obligation for a non-commercial restriction and throws away HYG's pre-merged cross-catalog identity resolution. HYG is the license-clean source; Gaia is the encumbered one.
5 · WebHID and Gamepad for HOTAS
| Blocks | M3, and a public promise |
| Question | Is many-axis HOTAS support achievable in a browser at all? |
| Answer | Yes — on Chromium, via WebHID. Nowhere else, and the Gamepad API alone is not enough. |
| Lives in | design/ux.md § Controls |
Measured — API surface, three browsers, same machine
| Chrome 151 | Safari 26.5 | Firefox 153 | |
|---|---|---|---|
navigator.hid |
present | absent | absent |
navigator.usb |
present | absent | absent |
navigator.getGamepads |
present | present | present |
Gamepad.vibrationActuator |
✅ | ✅ | legacy hapticActuators |
Mozilla's position on WebHID is negative, and it is a settled one rather than a backlog item. Verbatim:
This API, like WebUSB, provides access to generic devices. Though this API is limited to human interface devices (HID), the same concerns apply as WebUSB, namely that devices are generally not designed with access from arbitrary websites in their threat model.
WebKit's standards-positions issue is open with no position stated, and Safari has not shipped it. Plan for WebHID being Chromium-only indefinitely.
Measured — what the Gamepad API costs you, from Chromium's source
| Fact | Source |
|---|---|
kAxesLengthCap = 16 |
device/gamepad/public/cpp/gamepad.h |
kButtonsLengthCap = 32 |
same |
| Polling at 250 Hz on a dedicated thread | kPollingIntervalMilliseconds = 4 in device/gamepad/gamepad_provider.cc |
| Axes normalized from the element's own logical min/max at 8/16/32-bit report size | gamepad_device_mac.mm |
The button cap is worse than a cap. On macOS, buttons are indexed by HID usage number, and the code reads:
// Ignore buttons with large usage values.
if (button_index >= Gamepad::kButtonsLengthCap)
continue;A button whose usage number exceeds 32 is silently dropped — not packed into a free slot, not reported, gone. Axes get better treatment: high-usage axes are remapped into free indices in a second pass, up to 16. A HOTAS throttle with buttons declared above usage 32 will therefore appear to work and quietly lose inputs, which is the worst failure mode available.
Axis resolution is not a problem: normalization uses the device's own logical range at its native report size, so a 16-bit axis keeps all 65,536 steps. The 250 Hz poll downsamples a 1000 Hz device but is well inside what flight needs.
Measured — WebHID will actually talk to a stick
Chromium's protected-usage list (services/device/public/cpp/hid/hid_report_utils.cc,
IsAlwaysProtected) blocks the keyboard usage page and, on Generic Desktop:
Pointer, Mouse, Keyboard, Keypad, and the System Control ranges. Plus the FIDO
usage page and a vendor/product blocklist (Yubikey, Feitian, Titan…).
Joystick (0x04) and Gamepad (0x05) are not protected. A HOTAS top-level collection is reachable.
WebHID is event-driven (inputreport events, no polling interval), permission
persists per origin and is enumerated by getDevices(), requestDevice() needs a
user gesture and shows a device chooser, and device.forget() revokes.
What this means for the design
| Scheme | Where it works | Ceiling |
|---|---|---|
| Mouse + keyboard | Everywhere | None |
| Gamepad | Everywhere | 16 axes, 32 buttons, silent loss above usage 32 |
| HOTAS / HOSAS, full fidelity | Chromium only | The device's own limits |
HOTAS can be promised with the browser named. "Full 6-DoF axis binding with no emulation layer, in Chrome and Edge" is honest; "HOTAS support" unqualified is not, and would have to be withdrawn for half the audience.
Still unmeasured — and this is the part that still holds the promise
No HOTAS hardware was available. Everything above is API surface and browser source; none of it is a device test. Outstanding:
- Does a real stick-and-throttle pair enumerate as two independent devices, and stay stable across a reconnect?
- Do real devices put buttons above usage 32 in practice? (Virpil and VKB throttles are the suspects.)
- End-to-end latency versus the same device natively.
- Is there visible quantization in a slow roll?
- What does the chooser actually cost a player who has never seen one?
Run this on hardware before the promise goes in a README. The software half says it is possible; the hardware half says whether it is pleasant.
Related
- Roadmap — what is not built yet, and the seam for each
- Design bible — where each of these numbers now lives
design/appendix.md— the same five, from the design side- AGENTS.md — the rules that apply to anything that outlives a spike