Skip to content

Commit 963df1c

Browse files
feat: First person camera (#2179)
1 parent 392c5ab commit 963df1c

3 files changed

Lines changed: 293 additions & 35 deletions

File tree

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import * as m from 'wgpu-matrix';
2+
import { d, std } from 'typegpu';
3+
4+
export const Camera = d.struct({
5+
pos: d.vec4f,
6+
targetPos: d.vec4f,
7+
view: d.mat4x4f,
8+
projection: d.mat4x4f,
9+
viewInverse: d.mat4x4f,
10+
projectionInverse: d.mat4x4f,
11+
});
12+
13+
export interface CameraOptions {
14+
initPos?: d.v3f;
15+
target?: d.v3f;
16+
/**
17+
* Scrolling accelerates/decelerates the movement.
18+
* `d.vec3f(minimum, initial, maximum)`
19+
*/
20+
speed?: d.v3f;
21+
}
22+
23+
const cameraDefaults: Partial<CameraOptions> = {
24+
initPos: d.vec3f(0, 0, 0),
25+
target: d.vec3f(0, 1, 0),
26+
speed: d.vec3f(1, 1, 1),
27+
};
28+
29+
/**
30+
* Sets up a first person camera.
31+
* Calls the callback on scroll events, canvas clicks/touches and resizes.
32+
* Also, calls the callback during the setup with an initial camera.
33+
*/
34+
export function setupFirstPersonCamera(
35+
canvas: HTMLCanvasElement,
36+
partialOptions: CameraOptions,
37+
callback: (updatedProps: Partial<d.Infer<typeof Camera>>) => void,
38+
) {
39+
const options = { ...cameraDefaults, ...partialOptions } as Required<
40+
CameraOptions
41+
>;
42+
43+
// `runCallback` creates a Camera object based on the `cameraState` and passes it to the callback
44+
const cameraState = {
45+
pos: options.initPos,
46+
yaw: 0,
47+
pitch: 0,
48+
};
49+
50+
function runCallback() {
51+
const position = cameraState.pos;
52+
const pitch = cameraState.pitch;
53+
const yaw = cameraState.yaw;
54+
const target = position.add(d.vec3f(
55+
std.cos(pitch) * std.sin(yaw),
56+
std.sin(pitch),
57+
std.cos(pitch) * std.cos(yaw),
58+
));
59+
60+
const view = calculateView(position, target);
61+
const projection = calculateProj(canvas.clientWidth / canvas.clientHeight);
62+
63+
callback(Camera({
64+
pos: d.vec4f(position, 1),
65+
targetPos: d.vec4f(target, 1),
66+
view,
67+
projection,
68+
viewInverse: invertMat(view),
69+
projectionInverse: invertMat(projection),
70+
}));
71+
}
72+
73+
function rotateCamera(dx: number, dy: number) {
74+
const orbitSensitivity = 0.005;
75+
cameraState.yaw += -dx * orbitSensitivity;
76+
cameraState.pitch -= dy * orbitSensitivity;
77+
cameraState.pitch = std.clamp(
78+
cameraState.pitch,
79+
-Math.PI / 2 + 0.01,
80+
Math.PI / 2 - 0.01,
81+
);
82+
83+
runCallback();
84+
}
85+
86+
// resize observer
87+
const resizeObserver = new ResizeObserver(() => {
88+
runCallback();
89+
});
90+
resizeObserver.observe(canvas);
91+
92+
// Variables for interaction.
93+
const pressedKeys = new Set<string>();
94+
let moveSpeed = options.speed.y;
95+
96+
// keyboard events
97+
const keyDownEventListener = (event: KeyboardEvent) => {
98+
pressedKeys.add(event.key.toLowerCase());
99+
};
100+
window.addEventListener('keydown', keyDownEventListener);
101+
102+
const keyUpEventListener = (event: KeyboardEvent) => {
103+
pressedKeys.delete(event.key.toLowerCase());
104+
};
105+
window.addEventListener('keyup', keyUpEventListener);
106+
107+
// mouse events
108+
canvas.addEventListener('mousedown', () => {
109+
canvas.requestPointerLock();
110+
});
111+
112+
canvas.addEventListener('mousemove', (event: MouseEvent) => {
113+
if (document.pointerLockElement !== canvas) {
114+
return;
115+
}
116+
const dx = event.movementX;
117+
const dy = event.movementY;
118+
rotateCamera(dx, dy);
119+
});
120+
121+
canvas.addEventListener('wheel', (e) => {
122+
e.preventDefault();
123+
moveSpeed = std.clamp(
124+
moveSpeed * (1 - e.deltaY * 0.0005),
125+
options.speed.x,
126+
options.speed.z,
127+
);
128+
}, { passive: false });
129+
130+
function cleanupCamera() {
131+
window.removeEventListener('keydown', keyDownEventListener);
132+
window.removeEventListener('keyup', keyUpEventListener);
133+
resizeObserver.unobserve(canvas);
134+
}
135+
136+
// update position function
137+
const updatePosition = () => {
138+
if (document.pointerLockElement !== canvas) {
139+
return;
140+
}
141+
142+
const forward = std.normalize(d.vec3f(
143+
std.sin(cameraState.yaw),
144+
0,
145+
std.cos(cameraState.yaw),
146+
)).mul(moveSpeed);
147+
const left = d.vec3f(forward.z, 0, -forward.x);
148+
149+
if (pressedKeys.has('w')) {
150+
cameraState.pos = cameraState.pos.add(forward);
151+
}
152+
if (pressedKeys.has('s')) {
153+
cameraState.pos = cameraState.pos.sub(forward);
154+
}
155+
if (pressedKeys.has('a')) {
156+
cameraState.pos = cameraState.pos.add(left);
157+
}
158+
if (pressedKeys.has('d')) {
159+
cameraState.pos = cameraState.pos.sub(left);
160+
}
161+
if (pressedKeys.has('shift')) {
162+
cameraState.pos.y -= moveSpeed;
163+
}
164+
if (pressedKeys.has(' ')) {
165+
cameraState.pos.y += moveSpeed;
166+
}
167+
168+
runCallback();
169+
};
170+
171+
runCallback();
172+
return { cleanupCamera, updatePosition };
173+
}
174+
175+
function calculateView(position: d.v3f, target: d.v3f) {
176+
return m.mat4.lookAt(
177+
position,
178+
target,
179+
d.vec3f(0, 1, 0),
180+
d.mat4x4f(),
181+
);
182+
}
183+
184+
function calculateProj(aspectRatio: number) {
185+
return m.mat4.perspective(Math.PI / 4, aspectRatio, 0.1, 1000, d.mat4x4f());
186+
}
187+
188+
function invertMat(matrix: d.m4x4f) {
189+
return m.mat4.invert(matrix, d.mat4x4f());
190+
}

apps/typegpu-docs/src/examples/rendering/xor-dev-runner/index.ts

Lines changed: 68 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,14 @@
1212
* ```
1313
*/
1414

15-
import tgpu, { d } from 'typegpu';
15+
import tgpu, { d, std } from 'typegpu';
1616
// deno-fmt-ignore: just a list of standard functions
17-
import { abs, add, cos, max, min, mul, normalize, select, sign, sin, sub, tanh } from 'typegpu/std';
17+
import { abs, add, cos, max, min, mul, select, sign, sin, sub, tanh } from 'typegpu/std';
1818
import { defineControls } from '../../common/defineControls.ts';
19+
import {
20+
Camera,
21+
setupFirstPersonCamera,
22+
} from '../../common/setup-first-person-camera.ts';
1923

2024
// NOTE: Some APIs are still unstable (are being finalized based on feedback), but
2125
// we can still access them if we know what we're doing.
@@ -52,39 +56,61 @@ const rotateXZ = tgpu.fn([d.f32], d.mat3x3f)((angle) =>
5256
)
5357
);
5458

59+
export const Ray = d.struct({
60+
origin: d.vec4f,
61+
direction: d.vec4f,
62+
});
63+
64+
/**
65+
* Returns a ray direction and ray origin for given uv,
66+
* in accordance to camera.
67+
*/
68+
const getRayForUV = (uv: d.v2f) => {
69+
'use gpu';
70+
const camera = cameraUniform.$;
71+
const farView = camera.projectionInverse.mul(d.vec4f(uv, 1, 1));
72+
const farWorld = camera.viewInverse.mul(
73+
d.vec4f(farView.xyz.div(farView.w), 1),
74+
);
75+
const direction = std.normalize(farWorld.xyz.sub(camera.pos.xyz));
76+
return Ray({ origin: camera.pos, direction: d.vec4f(direction, 0) });
77+
};
78+
5579
// Roots are your GPU handle, and can be used to allocate memory, dispatch
5680
// shaders, etc.
5781
const root = await tgpu.init();
5882

5983
// Uniforms are used to send read-only data to the GPU
60-
const time = root.createUniform(d.f32);
61-
const scale = root.createUniform(d.f32);
62-
const color = root.createUniform(d.vec3f);
63-
const shift = root.createUniform(d.f32);
64-
const aspectRatio = root.createUniform(d.f32);
84+
const autoMoveOffsetUniform = root.createUniform(d.vec3f);
85+
const controlsOffsetUniform = root.createUniform(d.f32);
86+
const colorUniform = root.createUniform(d.vec3f);
87+
const shiftUniform = root.createUniform(d.f32);
6588

6689
const fragmentMain = tgpu['~unstable'].fragmentFn({
6790
in: { uv: d.vec2f },
6891
out: d.vec4f,
6992
})(({ uv }) => {
7093
// Increasing the color intensity
71-
const icolor = mul(color.$, 4);
72-
const ratio = d.vec2f(aspectRatio.$, 1);
73-
const dir = normalize(d.vec3f(mul(uv, ratio), -1));
94+
const icolor = mul(colorUniform.$, 4);
95+
96+
// Calculate ray direction based on UV and camera orientation
97+
const ray = getRayForUV(uv);
7498

7599
let acc = d.vec3f();
76100
let z = d.f32(0);
77101
for (let l = 0; l < 30; l++) {
78-
const p = sub(mul(z, dir), scale.$);
79-
p.x -= time.$ + 3;
80-
p.z -= time.$ + 3;
102+
const p = d.vec3f(3, 0, 3)
103+
.add(controlsOffsetUniform.$)
104+
.add(autoMoveOffsetUniform.$)
105+
.add(ray.origin.xyz)
106+
.add(mul(ray.direction.xyz, z));
81107
let q = d.vec3f(p);
82108
let prox = p.y;
83109
for (let i = 40.1; i > 0.01; i *= 0.2) {
84110
q = sub(i * 0.9, abs(sub(mod(q, i + i), i)));
85111
const minQ = min(min(q.x, q.y), q.z);
86112
prox = max(prox, minQ);
87-
q = mul(q, rotateXZ(shift.$));
113+
q = mul(q, rotateXZ(shiftUniform.$));
88114
}
89115
z += prox;
90116
acc = add(acc, mul(sub(icolor, safeTanh(p.y + 4)), 0.1 * prox / (1 + z)));
@@ -115,21 +141,33 @@ const presentationFormat = navigator.gpu.getPreferredCanvasFormat();
115141
const canvas = document.querySelector('canvas') as HTMLCanvasElement;
116142
const context = root.configureContext({ canvas, alphaMode: 'premultiplied' });
117143

144+
const cameraUniform = root.createUniform(Camera);
145+
const { cleanupCamera, updatePosition } = setupFirstPersonCamera(canvas, {
146+
speed: d.vec3f(0.001, 0.1, 1),
147+
}, (props) => {
148+
cameraUniform.writePartial(props);
149+
});
150+
118151
const pipeline = root['~unstable'].createRenderPipeline({
119152
vertex: vertexMain,
120153
fragment: fragmentMain,
121154
targets: { format: presentationFormat },
122155
});
123156

124157
let isRunning = true;
158+
let autoMove = true;
159+
let autoMoveOffset = d.vec3f();
125160

126-
function draw(timestamp: number) {
161+
function draw() {
127162
if (!isRunning) {
128163
return;
129164
}
130165

131-
aspectRatio.write(canvas.clientWidth / canvas.clientHeight);
132-
time.write((timestamp * 0.001) % 1000);
166+
if (autoMove && !document.pointerLockElement) {
167+
autoMoveOffset = autoMoveOffset.add(d.vec3f(0.01, 0, 0.01));
168+
autoMoveOffsetUniform.write(autoMoveOffset);
169+
}
170+
updatePosition();
133171

134172
pipeline
135173
.withColorAttachment({
@@ -147,13 +185,19 @@ requestAnimationFrame(draw);
147185
// #region Example controls and cleanup
148186

149187
export const controls = defineControls({
150-
scale: {
188+
'auto move': {
189+
initial: autoMove,
190+
onToggleChange(newValue) {
191+
autoMove = newValue;
192+
},
193+
},
194+
offset: {
151195
initial: 2,
152-
min: -15,
153-
max: 100,
196+
min: -100,
197+
max: 15,
154198
step: 0.01,
155199
onSliderChange(v) {
156-
scale.write(v);
200+
controlsOffsetUniform.write(v);
157201
},
158202
},
159203
'pattern shift': {
@@ -162,19 +206,20 @@ export const controls = defineControls({
162206
max: 200,
163207
step: 0.001,
164208
onSliderChange(v) {
165-
shift.write(v / 180 * Math.PI);
209+
shiftUniform.write(v / 180 * Math.PI);
166210
},
167211
},
168212
color: {
169213
initial: d.vec3f(1, 0.7, 0),
170214
onColorChange(value) {
171-
color.write(value);
215+
colorUniform.write(value);
172216
},
173217
},
174218
});
175219

176220
export function onCleanup() {
177221
isRunning = false;
222+
cleanupCamera();
178223
root.destroy();
179224
}
180225

0 commit comments

Comments
 (0)