Blackbird was a enjoyable, experimental web site that I used as a option to get acquainted with WebGL inside Strong.js. It went by means of the story of how the SR-71 was in-built tremendous technical element. The wireframe impact lined right here helped visualize the expertise beneath the floor of the SR-71 whereas maintaining the polished steel exterior seen that matched the websites aesthetic.
Right here is how the impact seems like on the Blackbird web site:
On this tutorial, we’ll rebuild that impact from scratch: rendering a mannequin twice, as soon as as a strong and as soon as as a wireframe, then mixing the 2 collectively in a shader for a clean, animated transition. The top consequence is a versatile approach you should utilize for technical reveals, holograms, or any second the place you need to present each the construction and the floor of a 3D object.
There are three issues at work right here: materials properties, render targets, and a black-to-white shader gradient. Let’s get into it!
However First, a Little About Strong.js
Strong.js isn’t a framework title you hear typically, I’ve switched my private work to it for the ridiculously minimal developer expertise and since JSX stays the best factor since sliced bread. You completely don’t want to make use of the Strong.js a part of this demo, you possibly can strip it out and use vanilla JS all the identical. However who is aware of, you might take pleasure in it 🙂
Intrigued? Try Strong.js.
Why I Switched
TLDR: Full-stack JSX with out the entire opinions of Subsequent and Nuxt, plus it’s like 8kb gzipped, wild.
The technical model: Written in JSX, however doesn’t use a digital DOM, so a “reactive” (assume useState()
) doesn’t re-render a complete element, only one DOM node. Additionally runs isomorphically, so "use consumer"
is a factor of the previous.
Setting Up Our Scene
We don’t want something wild for the impact: a Mesh, Digicam, Renderer, and Scene will do. I exploit a base Stage
class (for theatrical-ish naming) to manage when issues get initialized.
A World Object for Monitoring Window Dimensions
window.innerWidth
and window.innerHeight
set off doc reflow if you use them (extra about doc reflow right here). So I maintain them in a single object, solely updating it when essential and studying from the item, as an alternative of utilizing window
and inflicting reflow. Discover these are all set to 0
and never precise values by default. window
will get evaluated as undefined
when utilizing SSR, so we need to wait to set this till our app is mounted, GL class is initialized, and window
is outlined to keep away from all people’s favourite error: Can not learn properties of undefined (studying ‘window’).
// src/gl/viewport.js
export const viewport = {
width: 0,
peak: 0,
devicePixelRatio: 1,
aspectRatio: 0,
};
export const resizeViewport = () => {
viewport.width = window.innerWidth;
viewport.peak = window.innerHeight;
viewport.aspectRatio = viewport.width / viewport.peak;
viewport.devicePixelRatio = Math.min(window.devicePixelRatio, 2);
};
A Fundamental Three.js Scene, Renderer, and Digicam
Earlier than we are able to render something, we’d like a small framework to deal with our scene setup, rendering loop, and resizing logic. As a substitute of scattering this throughout a number of information, we’ll wrap it in a Stage
class that initializes the digital camera, renderer, and scene in a single place. This makes it simpler to maintain our WebGL lifecycle organized, particularly as soon as we begin including extra complicated objects and results.
// src/gl/stage.js
import { WebGLRenderer, Scene, PerspectiveCamera } from 'three';
import { viewport, resizeViewport } from './viewport';
class Stage {
init(component) {
resizeViewport() // Set the preliminary viewport dimensions, helps to keep away from utilizing window inside viewport.js for SSR-friendliness
this.digital camera = new PerspectiveCamera(45, viewport.aspectRatio, 0.1, 1000);
this.digital camera.place.set(0, 0, 2); // again the digital camera up 2 models so it is not on high of the meshes we make later, you will not see them in any other case.
this.renderer = new WebGLRenderer();
this.renderer.setSize(viewport.width, viewport.peak);
component.appendChild(this.renderer.domElement); // connect the renderer to the dom so our canvas reveals up
this.renderer.setPixelRatio(viewport.devicePixelRatio); // Renders greater pixel ratios for screens that require it.
this.scene = new Scene();
}
render() {
this.renderer.render(this.scene, this.digital camera);
requestAnimationFrame(this.render.bind(this));
// The entire scenes youngster lessons with a render technique can have it known as routinely
this.scene.kids.forEach((youngster) => {
if (youngster.render && typeof youngster.render === 'perform') {
youngster.render();
}
});
}
resize() {
this.renderer.setSize(viewport.width, viewport.peak);
this.digital camera.side = viewport.aspectRatio;
this.digital camera.updateProjectionMatrix();
// The entire scenes youngster lessons with a resize technique can have it known as routinely
this.scene.kids.forEach((youngster) => {
if (youngster.resize && typeof youngster.resize === 'perform') {
youngster.resize();
}
});
}
}
export default new Stage();
And a Fancy Mesh to Go With It
With our stage prepared, we can provide it one thing fascinating to render. A torus knot is ideal for this: it has loads of curves and element to point out off each the wireframe and strong passes. We’ll begin with a easy MeshNormalMaterial
in wireframe mode so we are able to clearly see its construction earlier than shifting on to the blended shader model.
// src/gl/torus.js
import { Mesh, MeshBasicMaterial, TorusKnotGeometry } from 'three';
export default class Torus extends Mesh {
constructor() {
tremendous();
this.geometry = new TorusKnotGeometry(1, 0.285, 300, 26);
this.materials = new MeshNormalMaterial({
colour: 0xffff00,
wireframe: true,
});
this.place.set(0, 0, -8); // Again up the mesh from the digital camera so its seen
}
}
A fast notice on lights
For simplicity we’re utilizing MeshNormalMaterial so we don’t need to mess with lights. The unique impact on Blackbird had six lights, waaay too many. The GPU on my M1 Max was choked to 30fps attempting to render the complicated fashions and realtime six-point lighting. However lowering this to simply 2 lights (which visually regarded equivalent) ran at 120fps no drawback. Three.js isn’t like Blender the place you may plop in 14 lights and torture your beefy pc with the render for 12 hours when you sleep. The lights in WebGL have penalties 🫠
Now, the Strong JSX Parts to Home It All
// src/parts/GlCanvas.tsx
import { onMount, onCleanup } from 'solid-js';
import Stage from '~/gl/stage';
export default perform GlCanvas() {
// let is used as an alternative of refs, these aren't reactive
let el;
let gl;
let observer;
onMount(() => {
if(!el) return
gl = Stage;
gl.init(el);
gl.render();
observer = new ResizeObserver((entry) => gl.resize());
observer.observe(el); // use ResizeObserver as an alternative of the window resize occasion.
// It's debounced AND fires as soon as when initialized, no must name resize() onMount
});
onCleanup(() => {
if (observer) {
observer.disconnect();
}
});
return (
);
}
let
is used to declare a ref, there isn’t a formal useRef()
perform in Strong. Alerts are the one reactive technique. Learn extra on refs in Strong.
Then slap that element into app.tsx
:
// src/app.tsx
import { Router } from '@solidjs/router';
import { FileRoutes } from '@solidjs/begin/router';
import { Suspense } from 'solid-js';
import GlCanvas from './parts/GlCanvas';
export default perform App() {
return (
(
{props.kids}
)}
>
);
}
Every 3D piece I exploit is tied to a selected component on the web page (normally for timeline and scrolling), so I create a person element to manage every class. This helps me maintain organized when I’ve 5 or 6 WebGL moments on one web page.
// src/parts/WireframeDemo.tsx
import { createEffect, createSignal, onMount } from 'solid-js'
import Stage from '~/gl/stage';
import Torus from '~/gl/torus';
export default perform WireframeDemo() {
let el;
const [element, setElement] = createSignal(null);
const [actor, setActor] = createSignal(null);
createEffect(() => {
setElement(el);
if (!component()) return;
setActor(new Torus()); // Stage is initialized when the web page initially mounts,
// so it is not obtainable till the following tick.
// A sign forces this replace to the following tick,
// after Stage is accessible.
Stage.scene.add(actor());
});
return ;
}
createEffect()
as an alternative of onMount()
: this routinely tracks dependencies (component
, and actor
on this case) and fires the perform once they change, no extra useEffect()
with dependency arrays 🙃. Learn extra on createEffect in Strong.
Then a minimal route to place the element on:
// src/routes/index.tsx
import WireframeDemo from '~/parts/WiframeDemo';
export default perform Dwelling() {
return (
);
}
Now you’ll see this:

Switching a Materials to Wireframe
I cherished wireframe styling for the Blackbird web site! It match the prototype really feel of the story, absolutely textured fashions felt too clear, wireframes are a bit “dirtier” and unpolished. You may wireframe nearly any materials in Three.js with this:
// /gl/torus.js
this.materials.wireframe = true
this.materials.needsUpdate = true;

However we need to do that dynamically on solely a part of our mannequin, not on the complete factor.
Enter render targets.
The Enjoyable Half: Render Targets
Render Targets are a brilliant deep matter however they boil all the way down to this: No matter you see on display is a body to your GPU to render, in WebGL you may export that body and re-use it as a texture on one other mesh, you’re making a “goal” to your rendered output, a render goal.
Since we’re going to want two of those targets, we are able to make a single class and re-use it.
// src/gl/render-target.js
import { WebGLRenderTarget } from 'three';
import { viewport } from '../viewport';
import Torus from '../torus';
import Stage from '../stage';
export default class RenderTarget extends WebGLRenderTarget {
constructor() {
tremendous();
this.width = viewport.width * viewport.devicePixelRatio;
this.peak = viewport.peak * viewport.devicePixelRatio;
}
resize() {
const w = viewport.width * viewport.devicePixelRatio;
const h = viewport.peak * viewport.devicePixelRatio;
this.setSize(w, h)
}
}
That is simply an output for a texture, nothing extra.
Now we are able to make the category that may eat these outputs. It’s lots of lessons, I do know, however splitting up particular person models like this helps me maintain monitor of the place stuff occurs. 800 line spaghetti mega-classes are the stuff of nightmares when debugging WebGL.
// src/gl/targeted-torus.js
import {
Mesh,
MeshNormalMaterial,
PerspectiveCamera,
PlaneGeometry,
} from 'three';
import Torus from './torus';
import { viewport } from './viewport';
import RenderTarget from './render-target';
import Stage from './stage';
export default class TargetedTorus extends Mesh {
targetSolid = new RenderTarget();
targetWireframe = new RenderTarget();
scene = new Torus(); // The form we created earlier
digital camera = new PerspectiveCamera(45, viewport.aspectRatio, 0.1, 1000);
constructor() {
tremendous();
this.geometry = new PlaneGeometry(1, 1);
this.materials = new MeshNormalMaterial();
}
resize() {
this.targetSolid.resize();
this.targetWireframe.resize();
this.digital camera.side = viewport.aspectRatio;
this.digital camera.updateProjectionMatrix();
}
}
Now, swap our WireframeDemo.tsx
element to make use of the TargetedTorus
class, as an alternative of Torus
:
// src/parts/WireframeDemo.tsx
import { createEffect, createSignal, onMount } from 'solid-js';
import Stage from '~/gl/stage';
import TargetedTorus from '~/gl/targeted-torus';
export default perform WireframeDemo() {
let el;
const [element, setElement] = createSignal(null);
const [actor, setActor] = createSignal(null);
createEffect(() => {
setElement(el);
if (!component()) return;
setActor(new TargetedTorus()); // << change me
Stage.scene.add(actor());
});
return ;
}
“Now all I see is a blue sq. Nathan, it really feel like we’re going backwards, present me the cool form once more”.
Shhhhh, It’s by design I swear!

From MeshNormalMaterial to ShaderMaterial
We are able to now take our Torus rendered output and smack it onto the blue airplane as a texture utilizing ShaderMaterial
. MeshNormalMaterial
doesn’t allow us to use a texture, and we’ll want shaders quickly anyway. Inside targeted-torus.js
take away the MeshNormalMaterial
and swap this in:
// src/gl/targeted-torus.js
this.materials = new ShaderMaterial({
vertexShader: `
various vec2 v_uv;
void essential() {
gl_Position = projectionMatrix * modelViewMatrix * vec4(place, 1.0);
v_uv = uv;
}
`,
fragmentShader: `
various vec2 v_uv;
various vec3 v_position;
void essential() {
gl_FragColor = vec4(0.67, 0.08, 0.86, 1.0);
}
`,
});
Now now we have a a lot prettier purple airplane with the assistance of two shaders:
- Vertex shaders manipulate vertex places of our materials, we aren’t going to the touch this one additional
- Fragment shaders assign the colours and properties to every pixel of our materials. This shader tells each pixel to be purple
Utilizing the Render Goal Texture
To indicate our Torus as an alternative of that purple colour, we are able to feed the fragment shader a picture texture through uniforms
:
// src/gl/targeted-torus.js
this.materials = new ShaderMaterial({
vertexShader: `
various vec2 v_uv;
void essential() {
gl_Position = projectionMatrix * modelViewMatrix * vec4(place, 1.0);
v_uv = uv;
}
`,
fragmentShader: `
various vec2 v_uv;
various vec3 v_position;
// declare 2 uniforms
uniform sampler2D u_texture_solid;
uniform sampler2D u_texture_wireframe;
void essential() {
// declare 2 photos
vec4 wireframe_texture = texture2D(u_texture_wireframe, v_uv);
vec4 solid_texture = texture2D(u_texture_solid, v_uv);
// set the colour to that of the picture
gl_FragColor = solid_texture;
}
`,
uniforms: {
u_texture_solid: { worth: this.targetSolid.texture },
u_texture_wireframe: { worth: this.targetWireframe.texture },
},
});
And add a render technique to our TargetedTorus
class (that is known as routinely by the Stage
class):
// src/gl/targeted-torus.js
render() {
this.materials.uniforms.u_texture_solid.worth = this.targetSolid.texture;
Stage.renderer.render(this.scene, this.digital camera);
Stage.renderer.setRenderTarget(this.targetSolid);
Stage.renderer.clear();
Stage.renderer.setRenderTarget(null);
}
THE TORUS IS BACK. We’ve handed our picture texture into the shader and its outputting our unique render.
Mixing Wireframe and Strong Supplies with Shaders
Shaders have been black magic to me earlier than this mission. It was my first time utilizing them in manufacturing and I’m used to frontend the place you assume in packing containers. Shaders are coordinates 0 to 1, which I discover far more durable to grasp. However, I’d used Photoshop and After Results with layers loads of occasions. These purposes do lots of the identical work shaders can: GPU computing. This made it far simpler. Beginning out by picturing or drawing what I needed, considering how I would do it in Photoshop, then asking myself how I may do it with shaders. Photoshop or AE into shaders is far much less mentally taxing if you don’t have a deep basis in shaders.
Populating Each Render Targets
For the time being, we’re solely saving information to the solidTarget
render goal through normals. We are going to replace our render loop, in order that our shader has them each this and wireframeTarget
obtainable concurrently.
// src/gl/targeted-torus.js
render() {
// Render wireframe model to wireframe render goal
this.scene.materials.wireframe = true;
Stage.renderer.setRenderTarget(this.targetWireframe);
Stage.renderer.render(this.scene, this.digital camera);
this.materials.uniforms.u_texture_wireframe.worth = this.targetWireframe.texture;
// Render strong model to strong render goal
this.scene.materials.wireframe = false;
Stage.renderer.setRenderTarget(this.targetSolid);
Stage.renderer.render(this.scene, this.digital camera);
this.materials.uniforms.u_texture_solid.worth = this.targetSolid.texture;
// Reset render goal
Stage.renderer.setRenderTarget(null);
}
With this, you find yourself with a move that below the hood seems like this:

Fading Between Two Textures
Our fragment shader will get somewhat replace, 2 additions:
- smoothstep creates a linear ramp between 2 values. UVs solely go from 0 to 1, so on this case we use
.15
and.65
as the bounds (they give the impression of being make the impact extra apparent than 0 and 1). Then we use the x worth of the uvs to outline which worth will get fed into smoothstep. vec4 combined = combine(wireframe_texture, solid_texture, mix);
combine does precisely what it says, mixes 2 values collectively at a ratio decided by mix..5
being a superbly even break up.
// src/gl/targeted-torus.js
fragmentShader: `
various vec2 v_uv;
various vec3 v_position;
// declare 2 uniforms
uniform sampler2D u_texture_solid;
uniform sampler2D u_texture_wireframe;
void essential() {
// declare 2 photos
vec4 wireframe_texture = texture2D(u_texture_wireframe, v_uv);
vec4 solid_texture = texture2D(u_texture_solid, v_uv);
float mix = smoothstep(0.15, 0.65, v_uv.x);
vec4 combined = combine(wireframe_texture, solid_texture, mix);
gl_FragColor = combined;
}
`,
And increase, MIXED:
Let’s be trustworthy with ourselves, this seems exquisitely boring being static so we are able to spice this up with little magic from GSAP.
// src/gl/torus.js
import {
Mesh,
MeshNormalMaterial,
TorusKnotGeometry,
} from 'three';
import gsap from 'gsap';
export default class Torus extends Mesh {
constructor() {
tremendous();
this.geometry = new TorusKnotGeometry(1, 0.285, 300, 26);
this.materials = new MeshNormalMaterial();
this.place.set(0, 0, -8);
// add me!
gsap.to(this.rotation, {
y: 540 * (Math.PI / 180), // must be in radians, not levels
ease: 'power3.inOut',
length: 4,
repeat: -1,
yoyo: true,
});
}
}
Thank You!
Congratulations, you’ve formally spent a measurable portion of your day mixing two supplies collectively. It was price it although, wasn’t it? On the very least, I hope this saved you some of the psychological gymnastics orchestrating a pair of render targets.
Have questions? Hit me up on Twitter!