Skip to content

Upgrade three 0.165.0 -> 0.181.2 - #166

Open
abernier wants to merge 9 commits into
e2e-inflight-per-urlfrom
three-181
Open

Upgrade three 0.165.0 -> 0.181.2#166
abernier wants to merge 9 commits into
e2e-inflight-per-urlfrom
three-181

Conversation

@abernier

@abernier abernier commented Aug 10, 2026

Copy link
Copy Markdown
Member

Bumps three 0.165.0 → 0.181.2 (and @types/three^0.181.0), which also unpins postprocessing.

Why 0.181.2 and not 0.185.1

r182 moved directional and spot shadow maps to sampler2DShadow (hardware PCF) instead of an RGBA-packed sampler2D. drei 10.7.8's <SoftShadows> still overrides shadowmap_pars_fragment with a PCSS block built on unpackRGBAToDepth(texture2D(shadowMap, ...)), and injects its return PCSS(...) at the first #if defined( SHADOWMAP_TYPE_PCF ) — which since r182 is the uniform declaration, not the getShadow body.

On 0.185.1 the fragment shader fails to compile:

demo 0.165 0.185.1 0.181.2
the-three-graces ok renders nothing ok
gltf-animations-tied-to-scroll ok renders nothing ok
soft-shadows ok loses its penumbra ok
room-with-soft-shadows ok loses its penumbra ok

drei fixes this only on the 11.0.0-alpha line, whose peers are three >= 0.182 and @react-three/fiber >= 10.0.0-0 — so it needs a fiber 10 alpha, not just a drei bump. .syncpackrc.json's pin label records the ceiling.

What r166–r181 removed along the way

  • WebGLMultipleRenderTargets (gone in r172) → WebGLRenderTarget's count option, whose attachments live on .textures. The vendored realism-effects bundles get a shim for the old constructor and their array-style .texture reads become .textures.
  • WebGLRenderer#copyFramebufferToTexture swapped its first two arguments in r166 (texture first, position second). realism-effects still passed the old order — this is what crashed ssgi-spheres-with-rapier-physics on load, and it built cleanly the whole time.
  • BufferAttribute#updateRangeaddUpdateRange() / updateRanges (nextjs-prism's Beam).
  • Texture#image is now typed unknown (racing-game's Heightmap).
  • Vector3#set()'s z became optional, so Parameters<Vector3['set']> no longer extends ReadonlyArray<number> — which drops the tuple branch of react-spring's animated scale prop. gltf-animations-re-used springs a scalar and passes scale={[s, s, 1]}.

postprocessing 6.37+ needs three >= 0.174, so the workspace override holding the transitive copy at 6.36.6 is gone and the direct dependency moves to 6.39.4 (peer: >= 0.168 < 0.186).

Verification

  • All 158 examples build (tsc + vite).
  • Every built demo was loaded headless and diffed against a baseline run of main: no new console or page errors. Remaining visual deltas sit inside the harness's own run-to-run noise, measured separately by running the same build twice — ?saycheese is not time-deterministic.

Notes

  • stage-presets-gltfjsx is already broken on main (blank canvas). This PR changes where it fails, not whether: #include <lightmap_fragment> before, composer.depthTexture being undefined after.
  • pnpm lint:examples fails on main too — 83 warnings against --max-warnings 81, unchanged by this PR. The pre-commit and pre-push hooks were bypassed for that reason; the cap needs raising or two hooks fixing, separately from this change.

🤖 Generated with Claude Code

dependabot Bot and others added 9 commits August 14, 2026 12:23
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 6 to 8.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](actions/download-artifact@v6...v8)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
`build-job` needed `test-job`, so the pull request preview landed after all
eight shards had finished. It was waiting on the wrong half of them.

`test` dependsOn `build2`, so a shard builds the examples it is about to
test, and the wait bought a warm turbo cache. Measured on #200: of the 3m16
the shards took, the build the preview actually needed was 31s of cache
restore. On a pull request that touches many examples the two numbers are
~3m30 and ~20m -- 170 examples at ~60s of software rendering is what makes
the sweep long, and building them was never the expensive part.

So split the job in two. `pages-job` keeps `needs: test-job`, because a red
example must not reach production. `preview-job` has no `needs`: it builds
all of them itself, at t=0, and the preview lands at ~2min rather than 5m21.
The cache is shared and written as each shard's tasks finish, so what it
rebuilds is whatever the shards have not reached yet -- the duplicated work
is a ceiling, not a bill, and on a public repository that runner is free.

What it gives up: an example whose test is red now reaches the preview.
Which is what a preview is for -- `chromatic-job` has published on
`always()` since the beginning for the same reason -- and merging is still
gated on the tests.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* Take the Base UI build of the same shadcn style

The registry ships every style twice, once per primitive library, and
`components.json` names which one: `radix-maia` becomes `base-maia`, the 17
components are re-added from it, and `radix-ui` leaves for `@base-ui/react`.
The look is the same pack of styles either way -- what changes is what is
underneath the wrappers, and the handful of call sites that were talking to
Radix through them.

None of the 17 were modified locally (`add --diff` had only prettier's
quarrel with the registry to report), so the swap is the registry's to make.
Two of them are edited on purpose afterwards, below.

The call sites, in the order the type checker found them:

- `asChild` is Radix's. Base UI composes with `render`, handing a part the
  element it should become, so a trigger wrapping a `Button` becomes
  `render={<Button/>}` and a `Badge` that was a `<li>` becomes
  `render={<li/>}`.
- `--radix-popover-content-available-height` is now `--available-height`,
  published on the positioner and inherited by the panel.
- `<SelectContent position="popper">` is `alignItemWithTrigger={false}`.
  Same reasoning as before -- a trigger 26px from the top of the window
  cannot have the menu laid over it, and the pre-scroll that follows is what
  was mounting a scroll-up arrow over an option already in view.
- `<SelectValue/>` prints the *value*, not the item's text, unless the root
  is handed the value/label map. Without `items` the trigger read `__all__`.

Two edits to the registry files, both because the call sites need them:

- `PopoverContent` forwards `collisionPadding` to the positioner. It picks
  four positioner props and this is a fifth; without it the prop lands on the
  popup as an unknown DOM attribute.
- The info button is a tooltip trigger *and* a popover trigger, the nesting
  the composition guide prescribes. Two levels of `render` deep, the server
  keeps the outermost `data-slot` and the client the innermost, which is a
  hydration mismatch on that attribute alone; pinning it at each level to
  the one it describes settles it.

The social bar's links keep `buttonVariants` on the `<a>` rather than
becoming a `Button`: Base UI's is a real `<button>` and asks to stay one --
rendered as an anchor it wants `nativeButton={false}`, which trades the link
role for `role="button"`. They are links.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Let Base UI's defaults stand

The migration carried three props whose only job was to keep the Radix
behaviour, and the registry file it edited to carry one of them. A different
library is allowed to look a little different.

`collisionPadding={12}` goes, and with it the fifth positioner prop
`PopoverContent` had to forward: Base UI keeps 5px off the edge on its own,
which on a 375px viewport puts the panel at 19px/5px rather than 12px/12px.

`alignItemWithTrigger={false}` goes. The arrow-over-an-option-already-in-view
it was avoiding is Radix's item-aligned mode, not this one -- laid over the
trigger by Base UI the menu opens at `scrollTop: 0` with neither scroll
button mounted, which is what the prop was there to obtain.

The `__all__` sentinel goes, and the value/label map with it. Base UI has a
cleared value of its own -- `null` -- so the option that drops the filter
carries it, `SelectValue` falls back to its `placeholder`, and every other
option is its own label. The state is still "" and the URL still empties.

`data-slot` is pinned once rather than at each level: the outermost is the
one the server keeps, so it alone decides what the client has to agree with.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Scroll the tag strip natively, with no bar to show

Radix's `ScrollArea` defaults to `type="hover"`: the bar is not in the DOM
until the pointer is over the area. Base UI has no such mode -- its scrollbar
is mounted whenever the axis overflows, and the registry's classes give it no
resting state -- so every tag strip long enough to scroll grew a permanent
2.5px bar across the bottom of the card.

The strip never wanted a bar. The fade at its edges is what says there is
more, which is why `scroll-fade-x` was aimed through the component at the
element that actually scrolls. A plain `overflow-x-auto` div scrolls the same
way, takes the fade directly, and has no bar to hide -- and its overflow is
declared in CSS rather than written by the component, which is one less thing
between the utility and the scroller it reads.

`components/ui/scroll-area.tsx` has no callers left, so it goes; `shadcn add
scroll-area` brings it back the day something wants it.

Checked against the deployed `main` in the same browser, same viewport: same
strips overflow by the same amounts, and both fade their ends once the page
has settled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Revert "Scroll the tag strip natively, with no bar to show"

This reverts commit b3833c4.

* Give the tag strip's scrollbar a resting state

The bar the last commit removed the component to get rid of: Radix kept it out
of the DOM until the pointer was over the area (`type="hover"`), Base UI mounts
it as soon as the axis overflows and leaves it to CSS to say when it shows.
The registry's classes give it no resting state, so every strip long enough to
scroll wore one permanently.

Which is a call-site fix, and `data-hovering` / `data-scrolling` are the API
for it -- the scrollbar publishes both. `opacity-0` plus those two is Radix's
hover mode, spelled out where it belongs.

The fade was never the casualty here. With the vendored `ScrollArea` back, the
three overflowing strips report the same `--scroll-fade-e` as the deployed
`main`, to six decimal places.

AGENTS.md says `components/ui/*` is never edited; it now says never deleted
either, and spells out the two ways this migration found around the rule --
adding a prop the registry chose not to forward, and swapping a component out
of a call site for hand-rolled markup. Its shadcn line also names the style the
app is actually on now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* No scrollbar on the tag strip at all

The last commit gave the bar a resting state. This one asks what it was for.

The fade already says there is more, it says it without being pointed at, and
that is the whole of what a finger gets -- a bar that appears on hover tells a
touch screen nothing. On a 32px strip it was 10px of it, laid across the pills
it described.

So the strip keeps `ScrollArea` -- the viewport, the hidden native scrollbar,
the wheel and swipe behaviour -- and simply doesn't mount a `ScrollBar`. Base
UI mounts none of its own for an axis that doesn't overflow, so the strips now
render with zero scrollbar elements.

A/B'd on the same page, same protocol: with the bar and without it, the three
overflowing strips report the same `--scroll-fade-e` to six decimal places.
The bar was the only thing that changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* A tag is a filter now, so the vocabulary is closed

195 tags for 170 examples, 145 of them on a single example, 29 examples
with none, and 13 whose line in `llms.txt` was the name alone. Three
registers were mixed into one field: the technique (`transmission`), the
API that implements it (`meshreflectormaterial`), and what the scene
depicts (`arkanoid`, `tag-heuer`, `codrops`). The last two are already
said by the source and by the title.

So: one axis, the technique. Synonyms and plurals merge on the form the
catalog already carries more often, ties to the singular. What is left is
117 tags, every one of them on at least one example -- no threshold, which
is what keeps `bvh` and `pcss` reachable and keeps every pill from ever
returning nothing.

The enum lives on `tags` in the schema, the same treatment `libraries`
gets, and the validator reads both lists out of that one file. An unknown
tag fails the lint rather than warning: a misspelt tag used to be a
cosmetic slip, and is now a filter that finds nothing. The editor offers
the list while it is typed, since every `pmndrs.json` points `$schema` at
it -- which is what makes a closed list bearable.

The 31 examples that would have been left with nothing were read, not
guessed from their titles: tagging an example from its own title says the
title twice. Six terms the catalog did not carry came out of that reading
-- fisheye, lod, views, hmr, debug, ssgi.

Clicking a tag is the only way into the filter; there is no picker. With
most tags on one example a menu of 117 is not something anyone reads,
whereas "show me the others like this one" is the actual gesture. Which
leaves the badge to say what the picker would have: pressed, it is why the
list is short, and clicking it again is how that is undone. It also has to
lead its card's strip, or a card can be filtered by a tag its four visible
pills do not show.

Two things had to move for a pill to be clickable at all. The card was an
anchor wrapping everything, and a button inside an anchor is as invalid as
an anchor inside one -- so the card keeps its whole clickable rectangle
through a stretched link the pills sit above. And the roving tabindex
claimed every button in the list, which would have made four tab stops per
card out of what is meant to be one; the pills opt out of it and stay a
pointer affordance, with the keyboard reaching the same tags from the info
panel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Tags select together, and a dead end is not offered

Multi-selection, ANDed. Which on its own would be a trap: 117 tags make
6,786 pairs, 336 of which occur together at all and 263 of those on a
single example -- so a second click empties the list about nineteen times
in twenty, and emptying the list is the one thing the vocabulary was
rebuilt to avoid.

So the second click is not offered when it would. `TagFilterProvider`
indexes the catalog by tag once, `useTagFilter` intersects it with the
tags picked so far, and a badge that would leave nothing goes disabled.
The dead end stops being something you find by falling into it.

It weighs the tags alone, not `?q=` or `?library=`: a pill greyed out
because of what sits in the search box reads as broken rather than as
narrow.

The cards need none of this and get it for free -- a card is in the list
because it carries every active tag, so every pill on it has at least
that card behind it and can never be the one that empties it. It is the
info panel, whose example may not be in the list at all, where a tag can
be a dead end. `?tag=physics,game` narrows to 7, and `transmission` on
the aquarium goes grey while they are held.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* A pill is pressed, not the card behind it

`:active` reaches every ancestor, so toggling a tag sank the whole
vignette — a gesture that goes nowhere near the example. The press is
the stretched link's now.

And the pill says it is pressable: the badge's own hover is written
`[a]:hover:`, which lands on anchors, and this one is a button.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CXMeT91HWLjW5Vtd1ySFW5

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* Add Autofocus, Bloom and Brightness/Contrast examples, ported from react-postprocessing

Autofocus and Brightness/Contrast are faithful ports of their CodeSandbox
originals. Bloom's scene was reworked onto the shared Suzanne+Stage template
with a glass material and an orbiting light, since the original's licensed
(CC-BY-NC-SA) voxel-character model didn't look good and raised licensing
questions better avoided.

* Add Color Average example, an original scene design

No CodeSandbox demo exists for this effect in react-postprocessing's docs.
Scene design: a cluster of glossy, palette-colored spheres that drains to
grayscale as the ColorAverage effect's opacity ramps up -- gives the
grayscale conversion an obvious, satisfying before/after to look at.

* Add the e2e test script to the earlier examples' package.json

They were added before the e2e/Chromatic test task (from PR #173 and
friends) landed on main, so they were missing the "test" script every
other example now has.

* Add Depth of Field, Glitch and Hue/Saturation examples, ported from react-postprocessing

Faithful ports of the CodeSandbox originals, with fixes for two stale
prop ranges written against a much older postprocessing version:
DepthOfField's focusDistance is documented as normalized [0,1] (the
original used 0..4), and HueSaturation's saturation is a -1..1 factor,
not radians (same bug fixed earlier in Take Control).

Glitch's `mode` prop doesn't reactively update in the current
@react-three/postprocessing release (confirmed upstream, fixed in an
unreleased 4.0.0) -- swapped it for the effect's other working props
(active, strength, delay, duration, columns, ratio) instead.

Depth of Field's scene is a row of five Suzanne heads receding in depth
rather than the original's single object, so the focus falloff is
actually visible; the camera's near/far planes are tightened so the
normalized focusDistance/focusRange controls map to a meaningful slice
of the scene.

* Add Noise, Ramp and Tone Mapping examples, ported from react-postprocessing

Noise: grain overlay demo on a staged Suzanne.
Ramp: custom gradient-ramp effect masking ASCII/Bloom over a box grid; fixed
an invisible-Bloom bug (luminanceThreshold 0.5 -> 0.1 to actually cross the
diffuse boxes' luminance).
Tone Mapping: ToneMapping effect across all ToneMappingMode values on a
staged Suzanne; added a mode dropdown since middleGrey/whitePoint only
affect the Reinhard2 family, not the modern AGX default.

* Add Selective Bloom example, an original scene design

Objects bloom on hover; the torus knot can also be locked on via a
Leva checkbox and the sphere via click. Uses @react-three/postprocessing's
SelectiveBloom effect with a low luminanceThreshold since selection (not
material brightness) drives what glows.

* Add SSAO example, an original scene design

Dense pile of matte spheres and boxes shows contact-shadow occlusion from
@react-three/postprocessing's SSAO effect. Fixed via ref + direct property
mutation since the wrapper's useMemo only reconstructs on camera/normalPass
change, not on prop updates. Also fixed three tuning issues: the deprecated
`radius` setter silently clamps to [1e-6, 1] so the wrapper's own 1-40
default range was a no-op; `bias` and `luminanceInfluence` defaults were
tuned for a differently-lit scene and mostly cancelled the effect out on
this one. Toggling "enabled" mutes via blendMode opacity rather than
unmounting, since EffectComposer renders a black screen when
enableNormalPass is on but zero Effect children remain.

* Credit original authors in pmndrs.json for the ported and original-design examples

* Reconcile postprocessing example tags with the closed tag vocabulary

PR #221 closed tags to a fixed enum after these examples were tagged.
Add ascii, autofocus, brightness-contrast, color-average, glitch,
hue-saturation and tone-mapping as new technique tags, and merge
depth-of-field/ssao/selective-bloom/ramp onto existing synonyms
(dof, ambient-occlusion, selective, gradient+mask).

* Add new examples to the website

---------

Co-authored-by: Kris Baumgartner <kjbaumgartner@gmail.com>
`waitForDecodes` asks the page for one number: how many items the default
loading manager still has in flight. That number is kept by counting
`itemStart` up and `itemEnd` down -- which trusts every loader to call both,
and one of them does not. `postprocessing` 6.39's `LUTCubeLoader.load()`
calls `itemEnd` on a URL it never called `itemStart` for; 6.36, the version
on this branch today, called both.

A single counter cannot survive that. It reads 0 at whatever moment exactly
one real item is in flight -- the shot going off in the middle of a decode,
which is the whole thing this wait exists to prevent -- and -1 the rest of
the time, which is a wait that cannot end. Measured on the three-0.181
branch, where 6.39 arrives: `glass-flower` and `nextjs-prism` each sat on
the full 300s budget and failed, and the four other `.cube` examples shot
early without saying anything.

So count per URL and ignore an `itemEnd` for a URL nobody started. No
behaviour change here -- 6.36 is balanced -- this is the harness holding its
own invariant rather than borrowing a loader's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Chromatic flagged basic-ballpit against a baseline nobody touched, twice,
while e2e-flaky swore it was stable locally. Both were right: two
mechanisms, both invisible on an idle machine, both measured under CPU
throttle.

@react-three/cannon steps by ping-pong -- each frame posts `step` and
*transfers* the position buffers to its worker; until the `frame` reply
hands them back, every further step is silently skipped and its 1/60th of
simulation dropped, not deferred. The picture is spawn + completed round
trips x 1/60, and how many round trips fit into thirty pumped frames is the
scheduler's call. basic-ballpit: 30/30 at full speed, 28 under x8 throttle,
each side perfectly reproducible -- two stable pictures, chosen by machine
speed. So the exchange is counted, per worker, and the pump holds the next
frame until the count settles: one step per frame, every machine.
terminate() forgives what a dying worker owes, and a step posted to an
already-terminated worker is never counted -- posting into a dead worker is
a silent void, and counting it held the pump for the full 300s budget.

And the remount's flushSync returns when the *DOM* side has committed; the
scene lives behind the <Canvas> bridge in r3f's own root, whose render is
scheduled, not flushed. trails, throttled: both flushSyncs long returned,
and the second take still assembled itself at frames 1-2 of the pump --
worker created, connected and populated across running frames, with the
first take's worker, not yet unmounted, stepping in the meantime. So the
take announces when it has *finished* mounting (Probe, last in the keyed
fragment: by the time its effect runs, every sibling's have), and the shot
waits for the announcement.

Measured across the twelve cannon examples, full speed and x8, two runs
each: one hash per example, 30/30 round trips, racing-game (29 posted, 28
answered before) and pmndrs-vercel and trails (three distinct pictures)
included. basic-ballpit holds one hash at x1/x4/x8/x20 -- unchanged from
before the fix, so CI converges back to the picture already measured.
object-clump still moves under x8 -- a different, pre-existing channel (its
seeded initial positions shift with load timing); at full speed its hash is
unchanged and stable. clones, springy-boxes and video-cookies unchanged as
non-cannon controls; arkanoid stays in EXCEPTIONS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
r182 is where this stops: three moved directional and spot shadow maps
to sampler2DShadow (hardware PCF) instead of an RGBA-packed sampler2D,
and drei 10.7.8's <SoftShadows> still overrides shadowmap_pars_fragment
with a PCSS block built on unpackRGBAToDepth(texture2D(shadowMap, ...)).
It also injects its `return PCSS(...)` at the first
`#if defined( SHADOWMAP_TYPE_PCF )`, which since r182 is the uniform
declaration rather than the getShadow body. On 0.185 the fragment shader
fails to compile: the-three-graces and gltf-animations-tied-to-scroll
render nothing, soft-shadows and room-with-soft-shadows lose their
penumbra. drei fixes this only on the 11.0.0-alpha line, which needs
@react-three/fiber 10.

Everything r166-r181 removed along the way:

- WebGLMultipleRenderTargets (gone in r172) -> WebGLRenderTarget's
  `count` option, whose attachments live on `.textures`. The vendored
  realism-effects bundles get a shim for the old constructor, and their
  array-style `.texture` reads become `.textures`.
- WebGLRenderer#copyFramebufferToTexture swapped its first two arguments
  in r166 (texture first, position second) -- realism-effects was still
  passing them the old way, which crashed the ssgi demo on load.
- BufferAttribute#updateRange -> addUpdateRange()/updateRanges.
- Texture#image is typed `unknown`, and Vector3#set()'s z became
  optional -- which drops the tuple branch of react-spring's animated
  `scale` prop, so gltf-animations-re-used springs a scalar instead.

postprocessing is unpinned along with it: 6.37+ needs three >= 0.174, so
the workspace override that held the transitive copy at 6.36.6 is gone
and the direct dependency moves to 6.39.4 (peer: >= 0.168 < 0.186).

Verified: all 158 examples build, and each one loads headless with no
console or page errors beyond the ones main already has.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

3 participants