<div class="stage">
<canvas aria-label="Orbit a reflective Motion logo in a 360 degree environment"></canvas>
</div>
<script type="module">
import { animate, frame, motionValue, press } from "motion"
import {
draw,
effect,
frame as vgpuFrame,
geometry,
init,
sampler,
surface,
target,
uniforms,
} from "vgpu"
import { group, orbitControls, perspectiveCamera } from "vgpu/scene"
const stage = document.querySelector(".stage")
const canvas = document.querySelector("canvas")
const ORBIT_SPEED = 0.09
const MAX_RELEASE_SPEED = 2.4
function runOrbitMotion(controls) {
const orbitYaw = motionValue(controls.yaw)
const orbitVelocity = motionValue(ORBIT_SPEED)
let velocityAnimation
let dragging = false
function transferDragVelocity() {
const releaseVelocity = Math.max(
-MAX_RELEASE_SPEED,
Math.min(MAX_RELEASE_SPEED, orbitYaw.getVelocity())
)
orbitVelocity.set(releaseVelocity)
velocityAnimation = animate(orbitVelocity, ORBIT_SPEED, {
duration: 2.4,
ease: [0.16, 1, 0.3, 1],
})
}
press(canvas, () => {
dragging = true
velocityAnimation?.stop()
velocityAnimation = undefined
orbitYaw.set(controls.yaw)
return () => {
if (!dragging) return
dragging = false
transferDragVelocity()
}
})
frame.update(({ delta }) => {
const seconds = Math.min(Math.max(0, delta) / 1000, 0.05)
controls.update(seconds)
if (dragging) {
orbitYaw.set(controls.yaw)
return
}
orbitYaw.set(orbitYaw.get() + orbitVelocity.get() * seconds)
controls.set({ yaw: orbitYaw.get() })
controls.update(0)
}, true)
}
const skyShader = `
const PI: f32 = 3.141592653589793;
struct Sky {
sun_direction: vec3f,
sun_angular_size: f32,
sun_color: vec3f,
sun_intensity: f32,
zenith_color: vec3f,
cloud_coverage: f32,
horizon_color: vec3f,
cloud_scale: f32,
ground_color: vec3f,
ground_scale: f32,
}
@group(0) @binding(0) var<uniform> sky: Sky;
fn directionFromEquirect(uv: vec2f) -> vec3f {
let phi = (uv.x - 0.5) * 2.0 * PI;
let theta = uv.y * PI;
return vec3f(
sin(theta) * cos(phi),
cos(theta),
sin(theta) * sin(phi)
);
}
fn hash(p: vec2f) -> f32 {
var q = fract(vec3f(p.x, p.y, p.x) * 0.1031);
q += dot(q, q.yzx + 33.33);
return fract((q.x + q.y) * q.z);
}
fn valueNoise(p: vec2f) -> f32 {
let i = floor(p);
let f = fract(p);
let w = f * f * (3.0 - 2.0 * f);
let a = hash(i);
let b = hash(i + vec2f(1.0, 0.0));
let c = hash(i + vec2f(0.0, 1.0));
let d = hash(i + vec2f(1.0, 1.0));
return mix(mix(a, b, w.x), mix(c, d, w.x), w.y);
}
fn fbm(p: vec2f) -> f32 {
var sum = 0.0;
var amplitude = 0.5;
var point = p;
for (var octave = 0; octave < 5; octave++) {
sum += amplitude * valueNoise(point);
point = point * 2.03 + vec2f(17.0, 9.0);
amplitude *= 0.5;
}
return sum;
}
fn cloudLayer(direction: vec3f, sun: vec3f) -> vec2f {
let height = max(direction.y, 0.035);
let plane = direction.xz / height * sky.cloud_scale;
let base = fbm(plane);
let detail = fbm(plane * 3.1 + vec2f(base * 1.6));
let density = smoothstep(
sky.cloud_coverage,
sky.cloud_coverage + 0.28,
base * 0.75 + detail * 0.35
);
let horizonFade = smoothstep(0.0, 0.12, direction.y);
let lit = pow(
clamp(dot(direction, sun) * 0.5 + 0.5, 0.0, 1.0),
3.0
);
return vec2f(density * horizonFade, lit);
}
fn checkerBox(p: vec2f, width: vec2f) -> f32 {
let i =
2.0 *
(
abs(fract((p - 0.5 * width) * 0.5) - 0.5) -
abs(fract((p + 0.5 * width) * 0.5) - 0.5)
) /
width;
return 0.5 - 0.5 * i.x * i.y;
}
fn ground(direction: vec3f, sun: vec3f) -> vec3f {
let depth = max(-direction.y, 0.001);
let plane = direction.xz / depth * sky.ground_scale;
let fade = 1.0 / (1.0 + dot(plane, plane) * 0.006);
var color =
sky.ground_color *
(
1.0 +
checkerBox(plane, fwidth(plane) + vec2f(1e-3)) * 3.4
);
color *= 0.8 + fbm(plane * 0.3) * 0.6;
color +=
sky.sun_color *
0.05 *
clamp(
dot(normalize(vec3f(plane.x, 0.0, plane.y)), sun),
0.0,
1.0
);
return mix(sky.horizon_color * 0.22, color, fade);
}
@fragment
fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f {
let direction = directionFromEquirect(uv);
let sun = normalize(sky.sun_direction);
let up = clamp(direction.y, 0.0, 1.0);
var color = mix(
sky.horizon_color,
sky.zenith_color,
pow(up, 0.75)
);
let sunDot = clamp(dot(direction, sun), 0.0, 1.0);
color += sky.sun_color * pow(sunDot, 60.0) * 0.2;
color += sky.sun_color * pow(sunDot, 900.0) * 0.8;
let disk = smoothstep(
cos(sky.sun_angular_size * 2.2),
cos(sky.sun_angular_size),
sunDot
);
color += sky.sun_color * sky.sun_intensity * disk;
let cloud = cloudLayer(direction, sun);
let cloudColor = mix(
vec3f(0.30, 0.34, 0.44),
sky.sun_color * 1.05,
cloud.y
);
color = mix(color, cloudColor, cloud.x * (1.0 - disk));
let horizon = smoothstep(-0.14, 0.02, direction.y);
color = mix(ground(direction, sun), color, horizon);
return vec4f(color, 1.0);
}
`
const logoShader = `
const PI: f32 = 3.141592653589793;
struct Camera {
viewProjection: mat4x4f,
position: vec3f,
}
struct Model {
model: mat4x4f,
}
struct Material {
color: vec3f,
}
struct Surface {
roughness: f32,
}
@group(0) @binding(0) var<uniform> camera: Camera;
@group(0) @binding(1) var<uniform> model: Model;
@group(0) @binding(2) var<uniform> material: Material;
@group(0) @binding(3) var<uniform> surface: Surface;
@group(0) @binding(4) var environmentMap: texture_2d<f32>;
@group(0) @binding(5) var environmentSampler: sampler;
struct VertexOut {
@builtin(position) position: vec4f,
@location(0) normal: vec3f,
@location(1) worldPosition: vec3f,
}
@vertex
fn vs_main(
@location(0) position: vec3f,
@location(1) normal: vec3f
) -> VertexOut {
var out: VertexOut;
let world = model.model * vec4f(position, 1.0);
out.position = camera.viewProjection * world;
out.normal = (model.model * vec4f(normal, 0.0)).xyz;
out.worldPosition = world.xyz;
return out;
}
fn equirectUv(direction: vec3f) -> vec2f {
let d = normalize(direction);
return vec2f(
atan2(d.z, d.x) / (2.0 * PI) + 0.5,
acos(clamp(d.y, -1.0, 1.0)) / PI
);
}
fn sampleEnvironment(direction: vec3f) -> vec3f {
return textureSampleLevel(
environmentMap,
environmentSampler,
equirectUv(direction),
0.0
).rgb;
}
@fragment
fn fs_main(
@location(0) normal: vec3f,
@location(1) worldPosition: vec3f
) -> @location(0) vec4f {
let n = normalize(normal);
let v = normalize(camera.position - worldPosition);
let reflected = reflect(-v, n);
let facing = max(dot(n, v), 0.0);
let fresnel =
material.color +
(vec3f(1.0) - material.color) * pow(1.0 - facing, 5.0);
let softenedDirection = normalize(
mix(reflected, n, surface.roughness * surface.roughness)
);
let specular = sampleEnvironment(softenedDirection) * fresnel;
let diffuse =
sampleEnvironment(n) *
material.color *
surface.roughness *
0.18;
return vec4f(specular + diffuse, 1.0);
}
`
const presentShader = `
const PI: f32 = 3.141592653589793;
struct PresentCamera {
forward: vec3f,
tanHalfFov: f32,
right: vec3f,
aspect: f32,
up: vec3f,
exposure: f32,
}
@group(0) @binding(0) var scene: texture_2d<f32>;
@group(0) @binding(1) var sceneSampler: sampler;
@group(0) @binding(2) var environmentMap: texture_2d<f32>;
@group(0) @binding(3) var environmentSampler: sampler;
@group(0) @binding(4) var<uniform> presentCamera: PresentCamera;
fn equirectUv(direction: vec3f) -> vec2f {
let d = normalize(direction);
return vec2f(
atan2(d.z, d.x) / (2.0 * PI) + 0.5,
acos(clamp(d.y, -1.0, 1.0)) / PI
);
}
fn tonemapAces(color: vec3f) -> vec3f {
let x = max(color, vec3f(0.0));
return clamp(
(x * (2.51 * x + 0.03)) /
(x * (2.43 * x + 0.59) + 0.14),
vec3f(0.0),
vec3f(1.0)
);
}
fn linearToSrgb(color: vec3f) -> vec3f {
let x = max(color, vec3f(0.0));
return select(
1.055 * pow(x, vec3f(1.0 / 2.4)) - 0.055,
x * 12.92,
x <= vec3f(0.0031308)
);
}
@fragment
fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f {
let ndc = vec2f(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
let direction = normalize(
presentCamera.forward +
presentCamera.right *
(ndc.x * presentCamera.tanHalfFov * presentCamera.aspect) +
presentCamera.up * (ndc.y * presentCamera.tanHalfFov)
);
let background = textureSampleLevel(
environmentMap,
environmentSampler,
equirectUv(direction),
0.0
).rgb;
let sceneColor = textureSample(scene, sceneSampler, uv);
let color = mix(background, sceneColor.rgb, sceneColor.a);
return vec4f(
linearToSrgb(tonemapAces(color * presentCamera.exposure)),
1.0
);
}
`
function bezier(points, from, control1, control2, to, steps = 10) {
for (let i = 1; i <= steps; i++) {
const t = i / steps
const u = 1 - t
points.push([
u * u * u * from[0] +
3 * u * u * t * control1[0] +
3 * u * t * t * control2[0] +
t * t * t * to[0],
u * u * u * from[1] +
3 * u * u * t * control1[1] +
3 * u * t * t * control2[1] +
t * t * t * to[1],
])
}
}
function logoOutlines() {
const left = [
[9.587, 0],
[4.57, 9],
[0, 9],
[3.917, 1.972],
]
bezier(
left,
[3.917, 1.972],
[4.524, 0.883],
[6.039, 0],
[7.301, 0]
)
const middle = [
[10.443, 0],
[15.013, 0],
[9.997, 9],
[5.427, 9],
]
const right = [
[15.841, 0],
[20.411, 0],
[16.494, 7.028],
]
bezier(
right,
[16.494, 7.028],
[15.887, 8.117],
[14.372, 9],
[13.11, 9]
)
right.push([10.825, 9])
const dot = []
for (let i = 0; i < 48; i++) {
const angle = (i / 48) * Math.PI * 2
dot.push([
23.079 + Math.cos(angle) * 2.285,
2.25 + Math.sin(angle) * 2.285,
])
}
const scale = 4 / 25.364
return [left, middle, right, dot].map((outline) =>
outline.map(([x, y]) => [
(x - 12.682) * scale,
(4.5 - y) * scale,
])
)
}
function extrude(outlines, depth) {
const vertices = []
const indices = []
const half = depth / 2
const push = (point, normal) => {
vertices.push(...point, ...normal)
return vertices.length / 6 - 1
}
for (let outline of outlines) {
let area = 0
for (let i = 0; i < outline.length; i++) {
const a = outline[i]
const b = outline[(i + 1) % outline.length]
area += a[0] * b[1] - b[0] * a[1]
}
if (area < 0) outline = [...outline].reverse()
const count = outline.length
const front = outline.map((point) =>
push([point[0], point[1], half], [0, 0, 1])
)
const back = outline.map((point) =>
push([point[0], point[1], -half], [0, 0, -1])
)
for (let i = 1; i < count - 1; i++) {
indices.push(front[0], front[i], front[i + 1])
indices.push(back[0], back[i + 1], back[i])
}
for (let i = 0; i < count; i++) {
const a = outline[i]
const b = outline[(i + 1) % count]
const length = Math.hypot(b[1] - a[1], b[0] - a[0])
const normal = [
(b[1] - a[1]) / length,
-(b[0] - a[0]) / length,
0,
]
const v0 = push([a[0], a[1], half], normal)
const v1 = push([b[0], b[1], half], normal)
const v2 = push([b[0], b[1], -half], normal)
const v3 = push([a[0], a[1], -half], normal)
indices.push(v0, v3, v2, v0, v2, v1)
}
}
return {
data: new Float32Array(vertices),
indices: new Uint32Array(indices),
}
}
function normalize(vector) {
const length = Math.hypot(...vector)
return vector.map((value) => value / length)
}
function subtract(a, b) {
return a.map((value, index) => value - b[index])
}
function cross(a, b) {
return [
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
]
}
function cameraBasis(position, targetPosition, fov, aspect) {
const forward = normalize(subtract(targetPosition, position))
const right = normalize(cross(forward, [0, 1, 0]))
const up = cross(right, forward)
return {
forward,
tanHalfFov: Math.tan((fov * Math.PI) / 360),
right,
aspect,
up,
exposure: 0.9,
}
}
async function start() {
if (!navigator.gpu) {
throw new Error("This example needs a browser with WebGPU")
}
const gpu = await init()
const canvasSurface = surface(gpu, canvas, { dpr: [1, 2] })
const sceneTarget = target(gpu, {
size: canvasSurface.size,
format: "rgba16float",
depth: true,
})
const environmentTarget = target(gpu, {
size: [2048, 1024],
format: "rgba16float",
})
const environmentSampler = sampler(gpu, {
minFilter: "linear",
magFilter: "linear",
addressModeU: "repeat",
addressModeV: "clamp-to-edge",
})
const environment = effect(gpu, skyShader, {
set: {
sky: {
sun_direction: [-0.724, 0.09, -0.684],
sun_angular_size: 0.018,
sun_color: [1, 0.88, 0.72],
sun_intensity: 26,
zenith_color: [0.05, 0.15, 0.44],
cloud_coverage: 0.56,
horizon_color: [0.36, 0.48, 0.74],
cloud_scale: 0.75,
ground_color: [0.05, 0.05, 0.056],
ground_scale: 4.6,
},
},
})
vgpuFrame(gpu, (currentFrame) => {
currentFrame.pass(environmentTarget, environment)
})
const logo = extrude(logoOutlines(), 0.25)
const logoGeometry = geometry(gpu, {
buffers: [
{
attributes: {
position: "float32x3",
normal: "float32x3",
},
data: logo.data,
},
],
indices: logo.indices,
})
const finish = uniforms(gpu, { roughness: 0.055 })
const cameraPosition = [2.4, 1.6, 7]
const cameraTarget = [0, 0, 0]
const fov = 32
const camera = perspectiveCamera({
fov,
aspect: canvasSurface.size[0] / canvasSurface.size[1],
position: cameraPosition,
target: cameraTarget,
})
const controls = orbitControls(camera, {
element: canvas,
damping: 0.12,
distance: { min: 4, max: 12 },
})
runOrbitMotion(controls)
let presentCamera = cameraBasis(
cameraPosition,
cameraTarget,
fov,
canvasSurface.size[0] / canvasSurface.size[1]
)
const node = group()
const logoDraw = draw(gpu, {
shader: logoShader,
geometry: logoGeometry,
cull: "back",
set: {
surface: finish,
environmentMap: environmentTarget,
environmentSampler,
},
})
const present = effect(gpu, presentShader, {
set: {
scene: sceneTarget,
sceneSampler: sampler(gpu, {
minFilter: "linear",
magFilter: "linear",
}),
environmentMap: environmentTarget,
environmentSampler,
presentCamera,
},
})
canvasSurface.onResize(({ width, height }) => {
sceneTarget.resize([width, height])
const aspect = width / height
camera.set({ aspect })
presentCamera = { ...presentCamera, aspect }
present.set({ presentCamera })
})
function render() {
presentCamera = cameraBasis(
camera.position,
cameraTarget,
fov,
canvasSurface.size[0] / canvasSurface.size[1]
)
present.set({ presentCamera })
logoDraw.set({
camera: {
viewProjection: camera.viewProjection,
position: camera.position,
},
model: { model: node.worldMatrix },
material: { color: [0.56, 0.57, 0.58] },
})
vgpuFrame(gpu, (currentFrame) => {
currentFrame.pass(
{ target: sceneTarget, clear: [0, 0, 0, 0] },
(pass) => pass.draw(logoDraw)
)
currentFrame.pass(canvasSurface, present)
})
}
frame.render(render, true)
}
start().catch((error) => {
stage.textContent = error.message
})
</script>
<style>
.stage {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
overflow: hidden;
background: var(--black);
}
.stage canvas {
display: block;
width: 100%;
height: 100%;
cursor: grab;
touch-action: none;
}
.stage canvas:active {
cursor: grabbing;
}
</style>