Skip to content

Text: gradient fills, moving labels, and the typings the docs promised - #1662

Merged
obiot merged 17 commits into
masterfrom
fix/text-gradient-bake
Sep 13, 2026
Merged

obiot merged 17 commits into
masterfrom
fix/text-gradient-bake

Conversation

@obiot

@obiot obiot commented Sep 13, 2026

Copy link
Copy Markdown
Member

Started as one Safari bug and turned into the thread it was hanging from.

The Text bakes

Gradient fills were clipped to the font box on Safari. WebKit rasterizes gradient-filled canvas text through a mask sized from the font's layout box rather than its ink, so glyphs rising above the declared ascent — routine for a display face — came out fully transparent under an intact stroke. Measured on a plain canvas with no engine involved: a flat fill paints 7px above the top alignment point on both engines, a gradient fill reaches 0 on WebKit. Gradient text is now filled flat and re-coloured through source-in, which runs every browser down the same rasterizer. Done unconditionally rather than behind a UA check.

A label that moved did not move. TextMetrics#x/y — the box the bake is blitted at — derives from pos, but nothing else in measureText does, so it refreshed only when the string changed. Reposition a label without re-setting its text and the bake offset grew by the whole distance travelled while the canvas stayed its original size: the glyphs slid off their own canvas and the blit went to where the label used to be. Split out as TextMetrics#updateOrigin, refreshed from the draw path.

The sub-pixel design is preserved rather than relocated: the canvas still floors onto a whole pixel (a fractional blit resamples the texture and goes soft) and the dropped fraction still lands in the bake where the rasterizer antialiases it. A new spec pins both halves — whole-pixel landing at fractional and negative positions, a whole-pixel move leaving the glyph phase bit-identical, a sub-pixel move moving the phase and not the canvas.

Ink padding was a pixel short. actualBoundingBox* describes the outline; antialiasing and pixel snapping paint past it. Measured a full pixel beyond on both engines, which was enough to shear the top row off a stroke.

The typings the docs promised

Type-checking an example against the shipped declarations found documented options the types denied. Mesh declared its 27 settings inline, so InstancedMesh — which forwards them wholesale and says so — rejected all of them; extracted as MeshSettings and intersected. setCurrentAnimation(name, { loop: true }), the form the class's own example uses, did not compile: the parameter was typed against the normalized options where loop and speed are mandatory. Stage#update and #draw were marked internal and stripped, so a custom stage could not call super.update(dt) in TypeScript while the skills and examples all teach exactly that.

Also Container#setChildsProperty's value, HTMLCanvasElement as image/texture, Text's bold/italic, Sprite3d's transparent/fog/castGroundShadow/shadowGroundY/shadowOpacity, and NoiseTexture2d's forwarded Noise settings.

One behaviour fix rides along: Sprite3d builds its own settings for Mesh and fog was not among the keys it copied, so fog: false — the only way to keep a sun out of the haze — silently did nothing.

Silent no-ops in the examples

Every one found by the type checker, every one code that reads correctly and does nothing:

  • new Light3d(0, 0, {…}) — the constructor takes options alone, so JavaScript dropped the object and both lights ran on defaults. A type: "ambient" written this way is a second directional light. Two shipped examples.
  • direction: new Vector3d(…) — an [x, y, z] array read by index, so a Vector3d yields NaN and the light contributes nothing. Fixing the arity is what exposed it; TypeScript stops checking a literal once the count is wrong.
  • and then the sign: direction is where the light travels, and this is a Y-down space, so a sun overhead is +Y. Both examples lit their subjects from underneath — never visible while the options were being discarded. See Light3d: accept a Vector3d for direction and position, as color already accepts a Color #1661 for accepting a Vector3d here.
  • blendMode in a settings literal — only the renderer reads that setting, never a renderable.

Audio

audio.tone() and audio.noise() take a delay in seconds, scheduled on the audio clock. Sequencing a stinger needed a setTimeout around the second call, which fires on the main thread and slips the note on a busy frame. Optional and defaulting to 0, so existing calls schedule exactly where they always did; the spec asserts that explicitly alongside the gap being the delay.

Also

ScoreFly in the plinko example was a Container wrapping one Text, because moving a Text did not move it — the wrapper held the label at its local origin and moved the container. It is a Text now. Its pos.set(x, y) was also wiping depth every frame, depth being pos.z.

The Camera3d (perspective) example is removed — three sprites stacked along z, written to prove per-sprite depth reached the vertex stream during the original Camera3d PRs.

Skills gained the Light3d traps and the Y-down sign, Camera3d#worldToScreen (which was in no skill at all), the settings-key and isRenderable no-ops, and a BitmapText section its own frontmatter had been advertising. The unreleased changelog section was trimmed from 594 characters an entry to 288; 18.3.0 is the reference at ~250.

Verification

Full suite 282 files / 6864 passed / 9 skipped. Typedoc 0 errors and 154 warnings, three below the starting point. Rendering verified on headless WebGL2, Chrome WebGPU and WebKit WebGPU throughout — the harness that does it is held back with the example that motivated it.

Both new specs were checked by removing the fix and confirming they go red.

obiot and others added 16 commits September 13, 2026 09:05
WebKit rasterizes gradient-filled canvas text through a mask it sizes from
the font's layout box rather than from the ink, so glyphs that rise above
the declared ascent — routine for a display face — came out transparent
under an intact stroke. Bake the fill flat and re-colour it through
`source-in` instead, which runs every browser down the same path.

Two defects fell out of the same bake while fixing it:

- The glyphs were drawn `inkPadTop` lower without the gradient moving with
  them, so the fill sampled further along the ramp than the caller authored
  and by a different amount per browser. Translate into the padding instead,
  which keeps bake space equal to metrics space.
- `actualBoundingBox*` describes the outline, but antialiasing and pixel
  snapping paint a full pixel past it, which sheared the top row off the
  stroke. Pad by one more pixel, and fall back to a share of the em where
  the metric is unavailable rather than to no padding at all.

The canvas bucket spec measured its waste against the layout box alone,
which ignored the ink padding the canvas also has to hold; it only passed
while the padding happened not to cross a 32px boundary. Rebased on the
padded box, plus the lower bound that would have caught the shearing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
The constructor hands its settings object to `new Noise(settings)` whole, and
the JSDoc said so in prose, but declared only the texture's own keys — so the
emitted type rejected `type`, `seed`, `octaves` and every other field setting.
The class's own `@example` would not have type-checked.

Type the parameter as an intersection with `NoiseSettings` instead of
transcribing its fields, which would drift the next time a setting is added
there. `colorRamp` was documented as a `Gradient` the file had no reference
to, so it emitted as `any`; it now resolves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
Two BitmapText settings were documented wrongly rather than thinly:

- `size` is a scaling RATIO against the font's authored size, but read as a
  pixel size — the same word means pixels on `Text`, so anyone moving between
  the two classes asks for 24 and gets 24x.
- `lineWidth` promised a stroke width. BitmapText has no stroke path and the
  constructor never reads the setting; the documentation was for a parameter
  that does not exist.

Its `fillStyle` also pointed `@see` at `BitmapText.tint`, which is inherited
from `Renderable` and did not resolve, and did so from inside the `@param`
text where it would not have rendered as a link either way.

Examples now cover what the classes are actually reached for: gradient fills
and their bake-local coordinates, tinting a bitmap font (and that white is the
absence of a tint rather than white text), rescaling by ratio, `measureText`
for sizing a panel, and tweening `visibleRatio` for a length-independent
typewriter reveal.

The UI and text skill advertised BitmapText in its description and triggers
and then never mentioned it in the body. It now carries the ratio trap, the
absent stroke, tinting, and the paired binary/image load.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
`TextMetrics#x/y` is the box the bake is blitted at, derived from `pos` — but
nothing else in `measureText` reads `pos`, so it was refreshed only when the
string changed. Move a label without re-setting its text and the bake offset
(`pos - metrics`) grew by the whole distance travelled while the canvas stayed
the size it was: the glyphs slid off their own canvas and were clipped, and the
blit still went to where the label used to be.

Split that origin out as `TextMetrics#updateOrigin` and refresh it from the
draw path. It reads `pos` and an already-measured width, and measures no
glyphs, so a per-frame call costs nothing.

The sub-pixel arithmetic is preserved rather than relocated: the canvas still
floors onto a whole pixel, because a texture blitted at a fractional coordinate
resamples and goes soft, and the fraction the floor drops still lands in the
bake where the font rasterizer antialiases it. For a label that has not moved,
`updateOrigin` recomputes what `measureText` already computed, from the same
inputs — so a stationary label is untouched by construction.

The new spec pins both halves: that the canvas lands on a whole pixel at
fractional and negative positions, that a whole-pixel move leaves the glyph
phase bit-identical while a sub-pixel move moves the phase and not the canvas,
and that the origin follows a long move. Its draw-path case fails with
`expected 10 to be 120` if the refresh is removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
Every one of these was found by type-checking an example against the shipped
declarations, and every one was documented behaviour the types denied.

`Mesh` declared its 27 settings inline on the constructor, so `InstancedMesh` —
which forwards them wholesale to `super` and says so in prose — rejected all of
them. Extracted as `MeshSettings` and intersected, the way `NoiseTexture2d`
now states its forwarded `Noise` settings: a subclass that passes the object
along says so once, instead of restating a list that drifts.

`setCurrentAnimation(name, { loop: true })` is the form the class's own example
uses, and it did not compile: the parameter was typed against the NORMALIZED
options that `parseAnimationOptions` returns, where `loop` and `speed` are
mandatory. Added `AnimationOptionsInput` for what a caller may pass.

`Container#setChildsProperty` took an `object`, refusing the booleans and
strings most properties actually hold. `image`/`texture` refused an
`HTMLCanvasElement` the renderer has always accepted.

`Stage#update` and `#draw` were marked internal and stripped from the
declarations, so a custom stage could not call `super.update(dt)` in TypeScript
— while the skills, the examples and the docs all teach exactly that. They are
the documented override points, so they are public now.

One behaviour fix rides with these: `Sprite3d` builds its own settings for
`Mesh` rather than forwarding yours, and `fog` was not among the keys it
copied — so `fog: false`, the only way to keep a sun out of the haze, silently
did nothing.

The settings shapes are exported and documented, which also removes three
pre-existing typedoc warnings: 157 down to 154.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
Each of these is code that reads correctly and does nothing, which is the kind
a skill is worth spending words on — nothing warns, and the scene looks
plausible enough that the value gets tuned instead of the call.

3D: `Light3d` already documented that it takes options only, and its two ways
to fail quietly did not. Extra positional arguments are dropped by JavaScript,
so `new Light3d(0, 0, {…})` makes `options` the number `0` and a
`type: "ambient"` light is a second directional one on defaults. And
`direction`/`position` are `[x, y, z]` arrays read by index, so a `Vector3d`
resolves to NaN and the light contributes nothing — while `color` on the same
object does take a `Color`. Fixing the first exposes the second, because
TypeScript stops checking a literal once the argument count is wrong.

Renderables: a settings key the class never reads is ignored in silence —
`blendMode` belongs to the renderer and to the renderable as a property, never
to a settings literal. And `isRenderable` gates `updateBounds`, not drawing, so
setting it false leaves the object on screen with stale bounds; `alpha` is what
hides something.

Camera: `worldToScreen` was in no skill at all, so pinning a label to a point
in a 3D scene had no documented answer and invited a hand-rolled `Matrix3d`
projection — which is exactly where the perspective divide and the
behind-the-camera case get missed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
`new Light3d(0, 0, {…})` and `new Light3d(0, 0, 0, {…})` pass the options
object where no parameter accepts it — the constructor takes options alone —
so JavaScript dropped it and every setting went with it. The key light's
direction, colour and intensity were discarded, and a `type: "ambient"` fill
was a second DIRECTIONAL light on defaults, which is why both scenes looked
lit at all.

`direction` is an `[x, y, z]` array read by index, not a `Vector3d`, so fixing
only the argument count turns the light's direction into NaN and the scene
black. Both had to move together.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
Entries had drifted to 594 characters on average against 18.3.0's ~250, and
read like incident reports — discovery order, why a bug went unnoticed, what
was tried first. That is a record of how the work went, not what changed for
someone using the engine.

State the mechanism and the symptom, then stop. 15 entries, now 288 average.
Released sections are untouched: each has a published GitHub release carrying
a copy, so editing them would only make the two diverge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
`new Light3d(0, 0, {…})` and `new Light3d(0, 0, 0, {…})` pass the options
object where no parameter accepts it — the constructor takes options alone — so
JavaScript dropped it and every setting went with it. The key light's
direction, colour and intensity were discarded, and a `type: "ambient"` fill
was a second DIRECTIONAL light on defaults, which is why both scenes looked lit
at all.

`direction` is an `[x, y, z]` array read by index, not a `Vector3d`, so fixing
only the argument count turns the direction into NaN and the scene black.

With the options finally reaching the engine, the direction was wrong too: this
is a Y-down space and `direction` is the way the light TRAVELS, so a sun
overhead is +Y. Both scenes had a negative Y and lit their subjects from
underneath — never visible before, because the value had never once been used.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
`ScoreFly` wrapped its label in a `Container` and moved the container, because
moving a `Text` did not move it — the wrapper kept the label pinned at its
local origin, where its stale bake offset stayed valid. The engine fix makes
the wrapper unnecessary, so the label is the renderable: one class, no child,
and the tween drives it directly. `preDraw` pivots a renderable's transform
around its own `pos`, so the pop-scale still scales in place.

Its `pos.set(x, y)` also wiped the depth every frame — the 2-argument form
defaults z to 0, and `depth` IS `pos.z`, so the fly sorted at 0 rather than the
200 its constructor asked for, despite a comment saying otherwise. Assigning x
and y leaves it alone.

`Text` reads `settings.bold` and `settings.italic` and documented neither, so
passing them was a type error in eight places across the examples.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
`direction` is the direction the light TRAVELS, and render space is Y-down, so
a sun overhead has a POSITIVE y. Neither the JSDoc nor the skill said so, and
two examples shipped with a negative one — lighting their subjects from
underneath, invisible for as long as the options were being discarded entirely.

`position` gets the same note: a lamp above the floor has a smaller y than the
floor. The skill gains the sign alongside the two silent failures already
documented there, plus a symptom row, since all three hide behind each other —
a wrong argument count stops TypeScript checking the literal, so the array and
then the sign only surface one fix at a time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
`window.setTimeout` does not know the game is paused, so the drop colour reset
its own second down while everything else was stopped. `timer.setTimeout` with
`pauseable` set follows the engine clock.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
Sequencing a multi-part sound needed a `setTimeout` around the second call,
because neither generator could be told to start later. A timer fires on the
main thread, so a busy frame slips the note and the sequence loses time
audibly — and the fault reads as sound design rather than scheduling.

Both generators already hang every ramp, start and stop off a single `t0`, so
this is one addition there. Optional and defaulting to 0, so a call that does
not mention it schedules exactly where it always did; negative values clamp
rather than scheduling in the past.

The two examples that were sequencing by timer — plinko's fanfare and
afterBurner's double-tap explosion — now use it.

The specs assert what the option is for: that the gap between two starts is
the delay, measured on the context's own clock, and that omitting it still
starts at `currentTime`. Removing the offset turns the first red with
`expected +0 to be close to 0.25`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
Three sprites stacked along z, proving that per-sprite depth reaches the vertex
stream and that the perspective matrix scales by it — a check written
alongside the original Camera3d PRs rather than something to show anyone. The
3D tier has real examples now.

Nothing else referenced it, and its monster sprite is borrowed from
`shaderEffects/assets`, which four other examples still use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
`shadowGroundY` and `shadowOpacity` reach `Mesh` — the constructor copies both
— but neither was in the `@param` list, so passing the floor height a scene
knows and the engine cannot guess was a type error. The billboard example does
exactly that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
Left by the audio entry's insertion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
Copilot AI lite review requested due to automatic review settings September 13, 2026 08:49

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

It points at `./examples/jungleRabbit/`, which is deliberately held back until
20.5 publishes, so the entry alone breaks the examples build — the module
cannot resolve. It was swept into the Camera3d removal by staging main.tsx
whole.

The entry goes back with the example it names.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
Copilot AI review requested due to automatic review settings September 13, 2026 08:53

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@obiot
obiot merged commit e9c8cc9 into master Sep 13, 2026
5 of 6 checks passed
@obiot
obiot deleted the fix/text-gradient-bake branch September 13, 2026 10:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants