Skip to content

Commit e3f22bb

Browse files
committed
Add a Panorama Video Example
1 parent b28ddf7 commit e3f22bb

File tree

6 files changed

+276
-0
lines changed

6 files changed

+276
-0
lines changed
Binary file not shown.

sample/panorama/index.html

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
6+
<title>webgpu-samples: panorama video</title>
7+
<!-- WebGPUCompatibilityMode origin token for https://webgpu.github.io expiring April 21, 2026 -->
8+
<meta
9+
http-equiv="origin-trial"
10+
content="Aktu7041jFm00ls336/bRinubASRzg1tPs4wxXOZkF1uP0LaIURinGC7ti0Vf352Q9OKFL1siRfpptLjNIKpKQcAAABheyJvcmlnaW4iOiJodHRwczovL3dlYmdwdS5naXRodWIuaW86NDQzIiwiZmVhdHVyZSI6IldlYkdQVUNvbXBhdGliaWxpdHlNb2RlIiwiZXhwaXJ5IjoxNzc2NzI5NjAwfQ=="
11+
/>
12+
<!-- WebGPUCompatibilityMode origin token for http://localhost:8080 expiring April 21, 2026 -->
13+
<meta
14+
http-equiv="origin-trial"
15+
content="AqW27Ayelg5vbcAaYcweU+sLjZq5r6idHCWU4MJgnkP1YBgmOMqazdGuakSnGylTkyA/bRHkCJZFdfYjFlylOgAAAABaeyJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjgwODAiLCJmZWF0dXJlIjoiV2ViR1BVQ29tcGF0aWJpbGl0eU1vZGUiLCJleHBpcnkiOjE3NzY3Mjk2MDB9"
16+
/>
17+
<style>
18+
:root {
19+
color-scheme: light dark;
20+
}
21+
html, body {
22+
margin: 0; /* remove default margin */
23+
height: 100%; /* make body fill the browser window */
24+
}
25+
canvas {
26+
width: 100%;
27+
height: 100%;
28+
max-width: 100%;
29+
display: block;
30+
}
31+
</style>
32+
<script defer src="main.js" type="module"></script>
33+
<script defer type="module" src="../../js/iframe-helper.js"></script>
34+
</head>
35+
<body>
36+
<canvas></canvas>
37+
</body>
38+
</html>

sample/panorama/main.ts

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import { GUI } from 'dat.gui';
2+
import panoramaExternalTextureWGSL from './sampleExternalTextureAsPanorama.wgsl';
3+
import { quitIfWebGPUNotAvailable } from '../util';
4+
import { mat4 } from 'wgpu-matrix';
5+
6+
const adapter = await navigator.gpu?.requestAdapter();
7+
const device = await adapter?.requestDevice();
8+
quitIfWebGPUNotAvailable(adapter, device);
9+
10+
// Set video element
11+
const video = document.createElement('video');
12+
video.loop = true;
13+
video.playsInline = true;
14+
video.autoplay = true;
15+
video.muted = true;
16+
video.src =
17+
'../../assets/video/Video_360°._Timelapse._Bled_Lake_in_Slovenia..webm.720p.vp9.webm';
18+
await video.play();
19+
20+
const canvas = document.querySelector('canvas') as HTMLCanvasElement;
21+
const context = canvas.getContext('webgpu') as GPUCanvasContext;
22+
const presentationFormat = navigator.gpu.getPreferredCanvasFormat();
23+
24+
context.configure({
25+
device,
26+
format: presentationFormat,
27+
});
28+
29+
const module = device.createShaderModule({
30+
code: panoramaExternalTextureWGSL,
31+
});
32+
33+
const pipeline = device.createRenderPipeline({
34+
layout: 'auto',
35+
vertex: { module },
36+
fragment: {
37+
module,
38+
targets: [
39+
{
40+
format: presentationFormat,
41+
},
42+
],
43+
},
44+
primitive: {
45+
topology: 'triangle-list',
46+
},
47+
});
48+
49+
const sampler = device.createSampler({
50+
magFilter: 'linear',
51+
minFilter: 'linear',
52+
});
53+
54+
const uniformBuffer = device.createBuffer({
55+
size: (16 + 2 + 2) * 4,
56+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
57+
});
58+
59+
const settings = {
60+
fov: 90,
61+
};
62+
63+
const gui = new GUI();
64+
gui.add(settings, 'fov', 3, 179).step(1).name('Field of View');
65+
let yRotation = 0;
66+
let xRotation = 0;
67+
68+
function frame() {
69+
const time = performance.now() / 1000;
70+
71+
canvas.width = canvas.clientWidth;
72+
canvas.height = canvas.clientHeight;
73+
74+
const bindGroup = device.createBindGroup({
75+
layout: pipeline.getBindGroupLayout(0),
76+
entries: [
77+
{
78+
binding: 0,
79+
resource: uniformBuffer,
80+
},
81+
{
82+
binding: 1,
83+
resource: device.importExternalTexture({
84+
source: video,
85+
}),
86+
},
87+
{
88+
binding: 2,
89+
resource: sampler,
90+
},
91+
],
92+
});
93+
94+
const commandEncoder = device.createCommandEncoder();
95+
const canvasTexture = context.getCurrentTexture();
96+
97+
const rotation = time * 0.1 + yRotation;
98+
const projection = mat4.perspective(
99+
(settings.fov * Math.PI) / 180,
100+
canvas.clientWidth / canvas.clientHeight,
101+
0.5,
102+
100
103+
);
104+
105+
// Note: You can use any method you want to compute a view matrix,
106+
// just be sure to zero out the translation.
107+
const camera = mat4.identity();
108+
mat4.rotateY(camera, rotation, camera);
109+
mat4.rotateX(camera, xRotation, camera);
110+
mat4.setTranslation(camera, [0, 0, 0], camera);
111+
const view = mat4.inverse(camera);
112+
const viewDirectionProjection = mat4.multiply(projection, view);
113+
const viewDirectionProjectionInverse = mat4.inverse(viewDirectionProjection);
114+
115+
const uniforms = new Float32Array([
116+
...viewDirectionProjectionInverse,
117+
canvasTexture.width,
118+
canvasTexture.height,
119+
]);
120+
device.queue.writeBuffer(uniformBuffer, 0, uniforms);
121+
122+
const renderPassDescriptor: GPURenderPassDescriptor = {
123+
colorAttachments: [
124+
{
125+
view: canvasTexture.createView(),
126+
clearValue: [0, 0, 0, 1],
127+
loadOp: 'clear',
128+
storeOp: 'store',
129+
},
130+
],
131+
};
132+
133+
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
134+
passEncoder.setPipeline(pipeline);
135+
passEncoder.setBindGroup(0, bindGroup);
136+
passEncoder.draw(3);
137+
passEncoder.end();
138+
device.queue.submit([commandEncoder.finish()]);
139+
140+
requestAnimationFrame(frame);
141+
142+
let startX = 0;
143+
let startY = 0;
144+
let startYRotation = 0;
145+
let startTarget = 0;
146+
147+
const clamp = (value: number, min: number, max: number) => {
148+
return Math.max(min, Math.min(max, value));
149+
};
150+
151+
const drag = (e: PointerEvent) => {
152+
const deltaX = e.clientX - startX;
153+
const deltaY = e.clientY - startY;
154+
yRotation = startYRotation + deltaX * 0.01;
155+
xRotation = clamp(
156+
startTarget + deltaY * -0.01,
157+
-Math.PI * 0.4,
158+
Math.PI * 0.4
159+
);
160+
};
161+
162+
const stopDrag = () => {
163+
window.removeEventListener('pointermove', drag);
164+
window.removeEventListener('pointerup', stopDrag);
165+
};
166+
167+
const startDrag = (e: PointerEvent) => {
168+
window.addEventListener('pointermove', drag);
169+
window.addEventListener('pointerup', stopDrag);
170+
e.preventDefault();
171+
startX = e.clientX;
172+
startY = e.clientY;
173+
startYRotation = yRotation;
174+
startTarget = xRotation;
175+
};
176+
canvas.addEventListener('pointerdown', startDrag);
177+
}
178+
179+
requestAnimationFrame(frame);

sample/panorama/meta.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export default {
2+
name: 'Panorama Video',
3+
description: `\
4+
This example draws a panorama video as a skymap using an external texture.
5+
Video by [Fabio Casati](https://commons.wikimedia.org/wiki/File:Video_360%C2%B0._Timelapse._Bled_Lake_in_Slovenia..webm)`,
6+
filename: __DIRNAME__,
7+
sources: [
8+
{ path: 'main.ts' },
9+
{ path: 'sampleExternalTextureAsPanorama.wgsl' },
10+
],
11+
};
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
struct Uniforms {
2+
viewDirectionProjectionInverse: mat4x4f,
3+
targetSize: vec2f,
4+
};
5+
6+
struct VSOutput {
7+
@builtin(position) position: vec4f,
8+
@location(0) uv: vec2f,
9+
};
10+
11+
@vertex
12+
fn vs(@builtin(vertex_index) vertexIndex: u32) -> VSOutput {
13+
let pos = array(
14+
vec2f(-1, -1),
15+
vec2f(-1, 3),
16+
vec2f( 3, -1),
17+
);
18+
19+
let xy = pos[vertexIndex];
20+
return VSOutput(
21+
vec4f(xy, 0.0, 1.0),
22+
xy * vec2f(0.5, -0.5) + vec2f(0.5)
23+
);
24+
}
25+
26+
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
27+
@group(0) @binding(1) var panoramaTexture: texture_external;
28+
@group(0) @binding(2) var panoramaSampler: sampler;
29+
30+
const PI = radians(180.0);
31+
@fragment
32+
fn main(@builtin(position) position: vec4f) -> @location(0) vec4f {
33+
let pos = position.xy / uniforms.targetSize * 2.0 - 1.0;
34+
let t = uniforms.viewDirectionProjectionInverse * vec4f(pos, 0, 1);
35+
let dir = normalize(t.xyz / t.w);
36+
37+
let longitude = atan2(dir.z, dir.x);
38+
let latitude = asin(dir.y / length(dir));
39+
40+
let uv = vec2f(
41+
longitude / (2.0 * PI) + 0.5,
42+
latitude / PI + 0.5,
43+
);
44+
45+
return textureSampleBaseClampToEdge(panoramaTexture, panoramaSampler, uv);
46+
}

src/samples.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import metaballs from '../sample/metaballs/meta';
2222
import multipleCanvases from '../sample/multipleCanvases/meta';
2323
import normalMap from '../sample/normalMap/meta';
2424
import occlusionQuery from '../sample/occlusionQuery/meta';
25+
import panorama from '../sample/panorama/meta';
2526
import particles from '../sample/particles/meta';
2627
import points from '../sample/points/meta';
2728
import pristineGrid from '../sample/pristineGrid/meta';
@@ -138,6 +139,7 @@ export const pageCategories: PageCategory[] = [
138139
textRenderingMsdf,
139140
volumeRenderingTexture3D,
140141
wireframe,
142+
panorama,
141143
},
142144
},
143145

0 commit comments

Comments
 (0)