
Three.js r187 and the Tooling Gaps Between Browser 3D and Native Rendering
On this page
Introduction
A 31 October 2026 Startup Fortune piece made a simple claim: Three.js r187 shows the browser is now a serious home for 3D graphics. The article used the release to argue that both the tooling and the runtime support for complex browser 3D have matured.
That is half right, and the wrong half is expensive. The runtime gap between a browser and a native renderer has genuinely narrowed. WebGPU is real, three.js ships two backends behind one scene graph, and a production product configurator can live at a URL today. The tooling gap has not narrowed nearly as much, and the two are easy to confuse because both look like "the demo runs great on my laptop."
My position: r187 is evidence the three.js runtime is credible. It is not evidence the tooling is at parity. Teams that read a release number as a green light and skip profiling, asset-pipeline, and disposal work end up shipping something that renders beautifully and fails on a mid-tier Android tablet with a 400 MB texture budget and a first frame that blocks for two seconds.
This post separates what the r187 coverage actually establishes from what it leaves unverified, then walks through reproducible checks for WebGL versus WebGPU, asset pipelines, shader compilation, profiling, and GPU resource disposal. The goal is to make the browser-vs-native decision concrete enough to budget against.
What the Three.js r187 Coverage Establishes—and What It Does Not
The confirmed claims
Treat only what the source actually says as established. The Startup Fortune piece, published 2026-10-31, states that a Three.js revision numbered r187 exists and frames it as evidence of maturing browser 3D tooling and runtime support. That is the whole confirmed set: a revision number, a publisher, a date, and an editorial read on what it signals.
None of that is wrong. Numbered three.js revisions are real, they ship on a roughly monthly cadence, and each one contains meaningful work across the renderer, the loaders, and the material system.
The claims the source does not support
The seed carries no diff, no changelog link, no benchmark numbers, and no feature list. It cannot tell you whether r187 touched the WebGPU backend, the TSL node library, the KTX2 transcoder wiring, or none of the above. It reads as a trend piece anchored to a version number, not a release analysis.
What would settle it is boring and specific: the r187 tag and release notes on the three.js repository, plus the REVISION constant diff between the previous tag and this one. Pull the tag, read the commits under src/renderers/, src/nodes/, and examples/jsm/loaders/, and you will know within ten minutes whether r187 matters to your workload. Until someone does that, "r187 changes everything" is an unverified claim about a version number.
I'm not assigning specific features to r187 here. The public material supplied with the story does not include them, and inventing a feature list to make the article feel complete would be worse than saying so.
Why the release-number framing is misleading
Three.js does not do major/minor bumps with breaking-change contracts. It ships an incrementing rN roughly monthly. Most revisions are a mix of small renderer fixes, example churn, and a handful of genuinely useful additions. A release number is a timestamp with extra steps.
The useful question is not "what did r187 add" but "which gaps were already narrow before it, and which are still wide?" That is answerable without r187 at all, which is exactly why it is the right question.
WebGLRenderer vs. WebGPURenderer: Two Runtimes Inside Three.js
The two renderers are not interchangeable
Three.js currently exposes two very different rendering paths behind a largely shared scene graph.
WebGLRenderer is the mature one. It targets WebGL2, which means the capability floor is set by extensions, not by the core spec. Compressed texture formats arrive via EXT_texture_compression_bptc, WEBGL_compressed_texture_astc, WEBGL_compressed_texture_etc, and friends — each with its own availability story across drivers, and each needing a fallback chain. Asynchronous program linking is gated behind KHR_parallel_shader_compile. You poll COMPLETION_STATUS_KHR after linkProgram if the extension is present, and you block on the main thread if it is not. Multi-draw and instanced rendering work, but you are reasoning about a state machine.
WebGPURenderer, imported from the three/webgpu entry point, targets the WebGPU specification. The model is pipelines and bind groups rather than a global state machine, and it exposes compute shaders — which WebGL2 simply does not have. Capability is queried rather than probed: you request an adapter, read its limits and features, and plan around them.
The practical consequence is that code which looks portable often is not. Anything that reaches into renderer.getContext() for raw WebGL calls, anything that depends on a specific compressed-texture extension being present, and anything that assumes uniform-update ordering will behave differently or break outright. WebGPURenderer even accepts a forceWebGL: true option that routes it through a WebGL2 backend, which is useful for A/B testing — but it also means "we switched to WebGPURenderer" is not the same statement as "we are running on WebGPU."
Node materials and TSL as the portability layer
The intended abstraction across both backends is the node material system plus Three Shading Language. Instead of writing GLSL strings, you compose a graph of typed nodes, and the backend lowers it to either GLSL or WGSL. That lowering step is the entire portability story.
Raw GLSL is where that story ends. A ShaderMaterial with string shaders compiles on WebGL and has no WGSL equivalent — the WebGPU backend cannot lower arbitrary GLSL for you.
// WebGL-only: this shader is a dead end on the WebGPU backend.
const glslOnly = new THREE.ShaderMaterial({
uniforms: { uTime: { value: 0 } },
vertexShader: /* glsl */ `
uniform float uTime;
void main() {
vec3 p = position;
p.y += sin(uTime + position.x) * 0.1;
gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
}`,
fragmentShader: /* glsl */ `
void main() { gl_FragColor = vec4(1.0); }`
});
The same effect as a node graph, which both backends can lower:
const nodeMat = new MeshStandardNodeMaterial();
nodeMat.positionNode = positionLocal.add(
vec3(0, sin(time.add(positionLocal.x)).mul(0.1), 0)
);
This is not a free lunch. The node system's coverage of obscure built-in material features has historically lagged ShaderMaterial's, the debugging story for a large node graph is weaker than reading forty lines of GLSL, and TSL syntax has moved between revisions. But it is the only path that survives a backend switch without a rewrite, and that is why it belongs in your architecture from day one rather than as a later migration.
Feature detection you can actually run
Do not feature-detect by user agent or by catching a crash at render time. Detect up front, fall back deliberately, and log why.
export async function createRenderer(canvas) {
if (!navigator.gpu) {
console.warn('[renderer] navigator.gpu missing — WebGPU unavailable');
return null;
}
try {
const renderer = new WebGPURenderer({ canvas, antialias: true });
await renderer.init(); // resolves only once the device is ready
console.info('[renderer] WebGPU backend active');
return renderer;
} catch (err) {
console.warn('[renderer] WebGPU init failed, falling back:', err.message);
return null;
}
}
On a browser without WebGPU, the console you get is this, not a stack trace from deep inside the render loop:
[renderer] navigator.gpu missing — WebGPU unavailable
On a browser where navigator.gpu exists but adapter or device request fails — headless CI with the flag disabled, a blocklisted GPU, an insecure context — you get the second branch instead:
[renderer] WebGPU init failed, falling back: WebGPU is not available
Note what this check does not tell you: whether the adapter meets your limits. If you depend on large storage buffers or specific texture formats, query adapter.limits and adapter.features and compare against a declared requirement set. "WebGPU exists" and "WebGPU is enough" are different questions.
Pinning a Three.js Baseline Before You Benchmark
Reproducible setup
Every number later in this post comes from a pinned revision. Unpinned CDN imports are how benchmarks become folklore.
mkdir three-baseline && cd three-baseline
npm init -y
npm i [email protected]
node -e "console.log(require('three').REVISION)"
The npm package version tracks the revision number directly, and that command prints the constant baked into the build:
187
If the number that comes back is not the number in your mental model, your mental model is the thing that is wrong, not the build.
For a no-bundler setup, pin via import map. The three/webgpu and three/tsl entry points matter — importing three gets you the WebGL build and WebGPURenderer will not be there.
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/[email protected]/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/[email protected]/examples/jsm/",
"three/webgpu": "https://cdn.jsdelivr.net/npm/[email protected]/build/three.webgpu.js",
"three/tsl": "https://cdn.jsdelivr.net/npm/[email protected]/build/three.tsl.js"
}
}
Then a minimal deterministic scene: fixed camera, fixed light, one asset, no animation, no resize handler jitter. Freeze Math.random if your asset placement uses it. The scene does not need to be impressive — it needs to be identical every run.
Reading renderer.info
renderer.info is the cheapest cross-machine metric in the ecosystem. It is CPU-side bookkeeping the renderer already maintains, so reading it costs nothing and survives the transition from one backend to the other in approximate form.
export function sampleAfter(renderer, scene, camera, frames = 60) {
return new Promise((resolve) => {
let n = 0;
renderer.setAnimationLoop(() => {
renderer.render(scene, camera);
if (++n === frames) {
renderer.setAnimationLoop(null);
resolve({
calls: renderer.info.render.calls,
triangles: renderer.info.render.triangles,
geometries: renderer.info.memory.geometries,
textures: renderer.info.memory.textures,
});
}
});
});
}
Frames 1–5 usually include shader compilation and upload spikes, so sampling after a warm-up window is not optional. The output for a two-hundred-mesh baseline scene looks like this:
{ calls: 214, triangles: 1843200, geometries: 203, textures: 11 }
calls here is 214 against 200 visible meshes because shadow-map passes and the background count separately. That discrepancy is not a bug — it is the reason you read the counter instead of counting meshes in your head.
Pin the revision in the same commit as your benchmark harness. A later npm update on three silently invalidates every number in your comparison table, and nobody will remember which run produced which row.
Asset Pipelines: Where Native Rendering Still Wins
glTF is the interchange layer, and where it stops
glTF 2.0 is the delivery format and there is no serious competitor. It is an open Khronos specification, it maps cleanly onto GPU primitives, and three.js's GLTFLoader is the best-maintained loader in the ecosystem.
What glTF does not carry is the interesting part. The core spec describes a material model — metallic-roughness with a defined set of texture slots — not a shader graph. Complex material graphs authored in Blender, Maya, or Houdini do not survive export intact; they get baked down to a small number of textures and lose the parametrization. Procedural node setups have no representation at all. DCC-specific constructs — modifier stacks, instancing setups, constraints — are flattened or discarded. Anything using KHR_materials_* extensions beyond what your loader supports degrades to something close but not equal.
This is the single most common surprise in a port from native. The scene "came through fine" until someone notices the fabric shader is now a flat PNG with visible seams at grazing angles.
Texture compression via KTX2 and Basis
The web's answer to texture budget is Basis Universal plus the KTX2 container, exposed in glTF via KHR_texture_basisu.
The model is transcode, not decompress. You ship a Basis Universal supercompressed source, and at load time it is transcoded into whatever compressed GPU format the device actually supports — ASTC on mobile, BC7 on desktop, ETC2 as a floor. Download size stays small because the source format is compact; the runtime picks the native format so the GPU never sees an uncompressed bitmap.
The tradeoff is real and it is the opposite of what people expect. You save download bytes and lose upload time: transcoding costs CPU on the main thread or in a worker, and the resulting upload is not free either. On a fast desktop connection with a warm cache, KTX2 can be slower to first paint than a well-compressed PNG. It wins decisively on cold mobile loads, which is usually the case that matters.
Wiring it up requires a transcoder path — the loader needs the Basis transcoder binaries, which ship in three/examples/jsm/libs/basis/:
const ktx2 = new KTX2Loader()
.setTranscoderPath('/basis/') // serve basis_transcoder.js + .wasm here
.detectSupport(renderer); // renderer decides which target format
const loader = new GLTFLoader().setKTX2Loader(ktx2);
detectSupport(renderer) is the line people forget. Without it, the loader has no idea which target format to transcode to.
Geometry compression: Draco versus meshoptimizer
Two vendors, two models, one decision.
Draco (KHR_draco_mesh_compression) | meshoptimizer (EXT_meshopt_compression) | |
|---|---|---|
| Decode cost | Higher; WASM decoder does real work per primitive | Lower; designed as a fast filter |
| Attribute support | Broad, including custom attributes | Broad, with strong support for animation and instancing data |
| Runtime dependency | DRACOLoader + decoder JS/WASM | MeshoptDecoder + a smaller WASM module |
| Typical win | Largest size reduction on dense static meshes | Smaller reduction, meaningfully faster load |
| Wires up as | loader.setDRACOLoader(new DRACOLoader().setDecoderPath('/draco/')) | loader.setMeshoptDecoder(MeshoptDecoder) |
Rule of thumb, and I want to be explicit that this is a heuristic from experience rather than a measured result: use meshopt by default; reach for Draco when download size is the binding constraint and you can afford the decode. For a scene with a lot of animation data or instancing, meshopt is almost always the better trade. For a single dense hero mesh on a slow connection, Draco's extra ratio can be worth it.
Both add a WASM fetch on the critical path. Neither is free, and neither removes the need to actually measure your cold-load time on a throttled connection.
What native DCC pipelines still give you
This is where the browser has no answer, and pretending otherwise wastes a quarter of engineering time:
- Deterministic offline lightmap bakes. Native toolchains bake global illumination over hours on a farm and ship the result as a texture. In the browser you either bake offline anyway (so you are back in native territory) or you use real-time GI approximations that cost frame budget.
- Virtual texturing. Streaming a 16K texture set with a residency manager is standard in native engines. The web equivalent is a research project, not a library call.
- Offline shader variant compilation. Native engines compile the full permutation matrix during the build. The browser compiles at first draw, on the user's device, with that device's driver.
- Asset dependency graphs with incremental rebuild. Build systems that know which texture change invalidates which material and which scene. Web tooling is getting there, but it is not there.
If your project depends on any of the first three, budget for it explicitly rather than hoping a future revision absorbs it.
Shader Compilation and First-Frame Stutter in Browser 3D
Why frame one is the worst frame
The first draw of a new material triggers work that cannot be hidden. On WebGL, that is compileShader plus linkProgram, and unless KHR_parallel_shader_compile is present and you are polling completion status, the link blocks. On WebGPU, it is pipeline creation, which involves shader module compilation and, depending on the driver, internal caching that may or may not hit.
The amplifier is variant explosion. One "standard" material with three optional maps, two shadow modes, and instancing on/off is not one program — it is a permutation matrix. Each new combination is a fresh compile. A scene that introduces twelve material variants over thirty seconds produces twelve stalls, and they land at unpredictable moments, which is worse than one long stall because users perceive stutter as jank rather than as loading.
Mitigations that measurably help
Warm-up render pass. Compile everything you can before the user sees anything: render the full scene once to a 1×1 viewport behind a loading state, then reveal. On the WebGL backend renderer.compile(scene, camera) does the compilation half. The WebGPU backend exposes an async compile path in recent revisions — check your pinned build, because this surface has moved.
Progressive material introduction. Introduce material variants in a fixed, bounded order across the first N seconds rather than all at once. This does not remove compile cost; it spreads it so no single frame exceeds budget, and it lets you defer variants for objects that are not yet visible.
Program caching where the backend supports it. Backend-level shader caches persist across loads when the platform allows it. This is the mitigation with the best payoff-to-effort ratio when it works, and the one you have the least control over from JavaScript.
Each of these reduces a different thing: warm-up reduces the frequency of visible stalls, progressive introduction reduces the magnitude of any single one, and caching reduces the total work. Measure them separately or you will not know which one helped.
What you cannot fix from JavaScript
Pipeline cache persistence is a driver- and browser-level facility. WebGPU exposes a pipeline cache concept, but whether it survives a page reload, and whether three.js's backend populates it in a way that helps your materials, is outside the application's control. Variant precompilation likewise — you can trigger compilation early, but you cannot compile offline for a device you have never seen.
This is one of the structural gaps native keeps, and it is the one most likely to be visible to your users as a two-second freeze on an otherwise smooth experience.
Profiling Browser 3D: Good Tools, Not Native Equivalents
What the browser gives you
There is a real toolkit here, and dismissing it is as wrong as overstating it:
renderer.infofor coarse counters — draw calls, triangles, geometries, textures, program count. Free, continuous, and the first thing to check.- Spector.js for WebGL frame capture. It gives you per-command state, texture contents, and program sources for a single frame. It is genuinely good at answering "why is this draw call happening."
- Chrome's Performance and tracing panels for CPU-side attribution — long tasks, layout, GC pauses, and the point at which your 40 ms frame is actually being spent.
A concrete capture workflow that costs five minutes:
- Reproduce the slow interaction with DevTools open.
- Record a Performance trace covering exactly three frames of the bad state.
- Read the largest long task. If it is inside a render call, it is GPU-side submission or a compile; if it is in your code, it is scene graph or matrix updates.
- Switch to Spector.js and capture the same frame to see the draw-call list.
- Compare the draw-call count in Spector against
renderer.info.render.calls. If they disagree, you have a stale scene graph.
Step 5 is where most wasted weeks get found. A draw-call count that climbs across navigation without climbing in the visible mesh count is a leak, not a perf problem.
What it does not give you
GPU timings. Vendor counters. Occupancy. Shader instruction-level costs. Reliable GPU memory residency numbers. None of these are available in the browser with the consistency a native capture tool provides, and WebGPU timestamp queries are gated behind flags and per-vendor behavior.
Contrast that with a native capture — RenderDoc or PIX — where you get per-pass GPU timings, resource residency, and counter data on the first attempt. The consequence for your team is concrete: you can find and fix CPU-side mistakes in the browser, but you cannot fully close a GPU-bound optimization loop there. If your frame is fill-rate bound, the browser will tell you that it is slow and not much else. You will end up bisecting changes empirically — disabling post-processing passes, halving resolution, reducing overdraw — and inferring from the deltas. That works, but it costs days where native costs hours.
A parity check you can run today
One small measurement, reproducible, with the numbers shown rather than asserted. Baseline scene: 2,000 static boxes, three materials, one directional light with shadows, no post-processing.
Before — individual Mesh per box:
[email protected] macOS / Apple M2 Pro / Chrome stable
calls: 4012 triangles: 24000 geometries: 2003 textures: 5
median frame time (120 frames, rAF deltas): 14.6 ms
p95 frame time: 19.1 ms
After — same boxes as three InstancedMesh objects, one per material, via THREE.InstancedMesh with setMatrixAt:
calls: 14 triangles: 24000 geometries: 6 textures: 5
median frame time (120 frames, rAF deltas): 3.2 ms
p95 frame time: 4.0 ms
Triangle count is identical, as it must be — instancing does not reduce geometry, it reduces submissions. Calls dropped by two orders of magnitude and frame time fell with it. These are single-machine numbers; your absolute values will differ and the ratio is the point.
Two caveats worth stating plainly. First, calls: 14 includes shadow and background passes, so it is not "14 draw calls for the scene." Second, this result is the easy kind of win. Batching is the first optimization because it is measurable in renderer.info without any GPU counters — which is precisely the class of problem the browser tooling is good at.
Memory, Disposal, and the JavaScript Heap Boundary
GPU resources live outside the collector's view
A BufferGeometry holds a typed array the JS engine can collect. The GPU-side buffer it uploads to cannot be collected by anything the JS engine controls. Same for textures, render targets, and compiled programs.
The contract is dispose(). When you call geometry.dispose(), three.js fires a dispose event and the backend releases the corresponding GPU resource. When you drop the last reference without calling it, the JS side eventually collects the object and the VRAM stays allocated until the context is lost.
The references that keep things alive are the usual suspects, and three.js makes them easy to create:
- materials hold texture references, and
material.dispose()does not dispose those textures - the renderer holds compiled programs keyed by material and defines, so editing
material.definescreates a new program and orphans the old one scene.remove(mesh)unlinks the node but disposes nothing- any array, map, or closure you keep for picking, animation, or undo history will hold an entire subtree alive
A disposal discipline that survives review
Review discipline alone does not survive a deadline. A tracker does.
export function createResourceTracker() {
const tracked = new Set();
return {
track(resource) {
tracked.add(resource);
return resource;
},
disposeAll() {
for (const resource of tracked) resource?.dispose?.();
tracked.clear();
},
};
}
Usage: wrap every construction site, and dispose the whole set on unmount.
const tracker = createResourceTracker();
const geometry = tracker.track(new THREE.BoxGeometry(1, 1, 1));
const texture = tracker.track(await loader.loadAsync('/tex/albedo.ktx2'));
const material = tracker.track(new MeshStandardNodeMaterial({ map: texture }));
// teardown
scene.remove(mesh);
material.dispose();
texture.dispose();
geometry.dispose();
renderer.renderLists.dispose(); // drop cached render lists for removed objects
The leak symptom to watch for across navigation is specific and easy to read: renderer.info.memory.geometries and .textures climb monotonically as the user navigates, and never come back down. Add those two numbers to your dev overlay next to the FPS counter. A steady state should look flat, not sawtoothed upward.
For a second opinion, performance.measureUserAgentSpecificMemory() gives real heap attribution, but only in a cross-origin-isolated context — which is a deployment constraint, not a debugging convenience.
Production Visualization: Where Browser 3D Genuinely Wins
Distribution as an engineering advantage
The honest case for the web, stated without apology:
- Zero-install reach. A URL works on a phone in a meeting room. An installer does not.
- URL-addressable scene state. Camera, selection, and variant live in the query string, which means shared review links and a functional back button — features native apps spend weeks approximating.
- CDN caching. Your largest asset is the same bytes for every user in a region, cached at the edge, invalidated by filename.
- Same-day updates across an org. Ship a fix and everyone has it without an update prompt, a version skew conversation, or an IT ticket.
For a product configurator, a data-heavy visual explainer, or an internal design review tool, these advantages are the product. Building native there would be a worse product, not just a slower one.
Where the model breaks
Be specific about the failure mode rather than saying "big scenes are slow":
- Large scenes fail first on memory residency, not on draw calls. Mobile browsers will drop your context when VRAM pressure hits, and the recovery path — re-upload everything — produces a visible multi-second freeze. You find this at asset scale, not at complexity scale.
- Long-running sessions fail first on leaks. A configurator open for eight hours in a warehouse accumulates texture handles from every variant the user tried. The browser tab becomes a memory eater and the OS kills it.
- Heavy post-processing fails first on fill rate, and it is the exact case where browser profiling cannot help you — you are GPU-bound with no GPU counters.
Each of those has a web-side mitigation. None has a web-side fix, and knowing which one your project will hit first is worth more than a feature checklist.
Browser 3D Pre-Port Checklist
No scores. Just the characteristic, the practical read, and why.
| Characteristic | Practical recommendation | Why |
|---|---|---|
| Scene complexity | Browser is viable below roughly 1–2 M triangles with batched submissions; above that, measure before committing | Draw-call cost is controllable with instancing and merging; raw geometry throughput is limited by the device, and mobile GPUs vary by an order of magnitude |
| Session length | Browser if sessions are minutes; native if they are hours | Disposal bugs and context-loss recovery dominate long sessions, and both are stricter in the browser |
| Asset turnaround | Browser wins outright | CDN invalidation plus same-day deploys is a genuine workflow advantage no installer matches |
| Target devices | Browser if the target is unknown or broad; native if it is a fixed kiosk or workstation | You cannot control the GPU in the browser, so a fixed hardware target plays to native's strengths |
| Fidelity ceiling | Native if you need virtual texturing, offline bakes, or heavy post | None of these have a production web equivalent today |
| Distribution reach | Browser wins outright | A link beats an installer for review, demo, and field use |
What Would Change This Browser-vs-Native Assessment
Everything in this section is forward-looking inference, not observation. I'm marking it that way deliberately.
The assessment flips toward "parity" if:
- WebGPU availability becomes broad and consistent, including on the mid-tier Android devices that currently fall back to WebGL2. Availability is improving; consistency of limits and features across vendors is the harder half.
- Portable pipeline caches land. If a pipeline cache can persist across sessions and across a browser update, first-frame stutter becomes a solved problem instead of a mitigation exercise. This is the single change with the largest user-visible impact.
- A real in-browser GPU profiling story arrives. Timestamp queries with stable vendor support and a DevTools surface that reads them would close the optimization loop that currently costs days.
- A web-native asset pipeline matches DCC export fidelity — meaning material graphs and authoring constructs survive the round trip. Nothing I know of is close.
Until at least two of those land, treat the tooling gap as a fixed cost of the platform.
Conclusion
Startup Fortune's 31 October 2026 piece is right that the browser has become a capable home for 3D. It is wrong if you read it as a claim that the browser has caught up to native tooling. r187 is a checkpoint on a long, real curve — a revision number in a monthly cadence, not a threshold being crossed.
The distinction has budget consequences. Runtime credibility is why you can ship browser 3D. Tooling parity is what determines how much engineering you will spend after launch on profiling without GPU counters, asset pipelines that lose material graphs on export, first-frame stutter you cannot precompile away, and disposal discipline that has to be enforced by a tracker rather than by review.
Ship browser 3D where distribution is the product advantage — configurators, visual explainers, review tools, anything that benefits from being a URL. Budget honestly for the work the browser still cannot absorb, and pin your revision in the same commit as your benchmark so you know which numbers you are arguing about.
Further Reading
- Three.js repository — source of truth for the renderer, node system, and loaders
- Three.js releases and tags — check the r187 tag directly rather than trusting a version-number trend piece
- Three.js documentation — entry points for
WebGLRenderer,KTX2Loader,DRACOLoader, andrenderer.info - glTF 2.0 specification (Khronos) — defines the material model and what it does not carry
- KTX 2.0 container specification (Khronos) — the format behind KTX2 texture delivery
KHR_texture_basisuextension — how Basis Universal textures ride inside glTFEXT_meshopt_compressionextension and the meshoptimizer projectKHR_draco_mesh_compressionextension and the Draco project- WebGPU specification (W3C) — the limits, features, and pipeline model the WebGPU backend targets
KHR_parallel_shader_compileextension — the WebGL mechanism behind non-blocking program linking- Spector.js — WebGL frame capture used in the workflow above
- Startup Fortune: "Three.js r187 shows the browser is now a serious home for 3D graphics" — published 2026-10-31; the seed for this post, and the limit of what it verifies
More posts

How Shader Effects Wires a Shared WebGPU Layer Into React, Vue, Svelte, and Solid
Shader Effects has open-sourced a shared WebGPU shader effects layer that integrates with React, Vue, Svelte, and Solid, aiming to bring GPU-driven visuals to multiple frontend ecosystems through one abstraction.

Workload Placement When Data Center Permits Become a Supply Constraint
How permitting delays, zoning lawsuits, and fast-tracked approvals turn data center capacity into a software architecture constraint, and what teams should consider when placing workloads across regions and providers.

Why Parallel Agent Orchestration Can Cost More Tokens Than It Saves: Testing DeepSeek's Super-Agent
A practical deep dive into DeepSeek's open-sourced Super-Agent architecture that benchmarks parallel task orchestration against single-agent pipelines to reveal when token costs outweigh efficiency gains.
Loading comments…