Skip to content

Commit 55bb275

Browse files
authored
Deform mesh (#21)
* Deform the mesh on drag/zoom. * PR stuff. * Util folder. * Subdivie based on zoom.
1 parent f27a3f7 commit 55bb275

26 files changed

Lines changed: 651 additions & 248 deletions

app/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
"tsx": "^4.20.5",
5555
"typescript": "~5.8.3",
5656
"vite": "^6.3.5",
57+
"vite-plugin-glsl": "^1.5.4",
5758
"vite-plugin-solid": "^2.11.8",
5859
"vite-plugin-static-copy": "^2.1.0",
5960
"vite-plugin-wrangler": "^0.1.1",

app/src/components/meme-selector.tsx

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,12 @@ export function MemeSelector(props: MemeSelectorProps): JSX.Element {
117117
setPlayingAudioMeme(null);
118118

119119
if (type === "audio") {
120-
const audio = new Audio(new URL(`/meme/${MEME_AUDIO[memeName as keyof typeof MEME_AUDIO].file}`, import.meta.env.VITE_APP_URL).toString());
120+
const audio = new Audio(
121+
new URL(
122+
`/meme/${MEME_AUDIO[memeName as keyof typeof MEME_AUDIO].file}`,
123+
import.meta.env.VITE_APP_URL,
124+
).toString(),
125+
);
121126
audio.volume = 0.5; // Lower volume for preview
122127
audio.play();
123128
setPreviewAudio(audio);
@@ -131,7 +136,10 @@ export function MemeSelector(props: MemeSelectorProps): JSX.Element {
131136
} else {
132137
// For video, play with sound
133138
const video = document.createElement("video");
134-
video.src = new URL(`/meme/${MEME_VIDEO[memeName as keyof typeof MEME_VIDEO].file}`, import.meta.env.VITE_APP_URL).toString();
139+
video.src = new URL(
140+
`/meme/${MEME_VIDEO[memeName as keyof typeof MEME_VIDEO].file}`,
141+
import.meta.env.VITE_APP_URL,
142+
).toString();
135143
video.volume = 0.5;
136144
video.style.display = "none";
137145
document.body.appendChild(video);
@@ -310,7 +318,10 @@ export function MemeSelector(props: MemeSelectorProps): JSX.Element {
310318
<div class="group relative bg-white/10 hover:bg-white/20 rounded overflow-hidden transition-colors cursor-pointer aspect-video basis-42 flex-grow">
311319
{/* Thumbnail background */}
312320
<img
313-
src={new URL(`/meme/${thumbnailName}`, import.meta.env.VITE_APP_URL).toString()}
321+
src={new URL(
322+
`/meme/${thumbnailName}`,
323+
import.meta.env.VITE_APP_URL,
324+
).toString()}
314325
alt={meme}
315326
class="absolute inset-0 w-full h-full opacity-30"
316327
style={{
@@ -321,7 +332,10 @@ export function MemeSelector(props: MemeSelectorProps): JSX.Element {
321332
{/* Video preview when playing */}
322333
<Show when={isPlaying()}>
323334
<video
324-
src={new URL(`/meme/${memeData.file}`, import.meta.env.VITE_APP_URL).toString()}
335+
src={new URL(
336+
`/meme/${memeData.file}`,
337+
import.meta.env.VITE_APP_URL,
338+
).toString()}
325339
autoplay
326340
muted
327341
class="absolute inset-0 w-full h-full opacity-70"

app/src/room/broadcast.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Captions } from "./captions";
66
import { Chat } from "./chat";
77
import { FakeBroadcast } from "./fake";
88
import { Bounds, Vector } from "./geometry";
9+
import { MeshBuffer } from "./gl/mesh";
910
import { Meme } from "./meme";
1011
import { Name } from "./name";
1112
import { Sound } from "./sound";
@@ -52,6 +53,22 @@ export class Broadcast<T extends BroadcastSource = BroadcastSource> {
5253
bounds: Signal<Bounds>; // 0 to canvas
5354
velocity = Vector.create(0, 0); // in pixels per ?
5455

56+
// Drag point in normalized coordinates (0-1) relative to the broadcast
57+
dragPoint = new Signal<Vector>(Vector.create(0.5, 0.5));
58+
59+
// Deformation velocity for the drag effect (decays independently from physics velocity)
60+
deformVelocity = Vector.create(0, 0);
61+
62+
// Zoom deformation for scaling effect (positive = expanding, negative = contracting)
63+
// Only applies during user-initiated zooming (mouse wheel or pinch)
64+
zoomDeform = 0;
65+
66+
// Zoom center point in normalized coordinates (0-1) relative to the broadcast
67+
zoomCenter = new Signal<Vector>(Vector.create(0.5, 0.5));
68+
69+
// Shared mesh buffer for all renderers
70+
mesh: MeshBuffer;
71+
5572
// Replaced by position
5673
//targetPosition = Vector.create(0, 0); // -0.5 to 0.5, sent over the network
5774
//targetScale = 1.0; // 1 is 100%
@@ -83,6 +100,9 @@ export class Broadcast<T extends BroadcastSource = BroadcastSource> {
83100
this.visible = new Signal(true); // TODO
84101
this.scale = props.scale;
85102

103+
// Create shared mesh buffer
104+
this.mesh = new MeshBuffer(props.canvas);
105+
86106
// Unless provided, start them at the center of the screen with a tiiiiny bit of variance to break ties.
87107
const start = () => (Math.random() - 0.5) / 100;
88108
const position = {
@@ -200,6 +220,9 @@ export class Broadcast<T extends BroadcastSource = BroadcastSource> {
200220
this.audio.tick();
201221
this.video.tick(now);
202222

223+
// Update mesh based on deformation velocity and zoom deformation
224+
this.mesh.update(this.deformVelocity, this.zoomDeform);
225+
203226
// Update opacity based on online status
204227
const fadeTime = 300; // ms
205228
const elapsed = now - this.#onlineTransition;
@@ -279,6 +302,21 @@ export class Broadcast<T extends BroadcastSource = BroadcastSource> {
279302

280303
// Slow down the velocity for the next frame.
281304
this.velocity = this.velocity.mult(0.5);
305+
306+
// Decay the deformation velocity smoothly over time (faster decay than physics velocity)
307+
if (this.deformVelocity.length() > 0.01) {
308+
this.deformVelocity = this.deformVelocity.mult(0.85);
309+
} else {
310+
this.deformVelocity = Vector.create(0, 0);
311+
}
312+
313+
// Decay zoom deformation (set by user interaction in Space)
314+
// Slower decay than drag to keep mesh subdivided during zoom animation
315+
if (Math.abs(this.zoomDeform) > 0.01) {
316+
this.zoomDeform *= 0.95;
317+
} else {
318+
this.zoomDeform = 0;
319+
}
282320
}
283321

284322
// Returns true if the broadcaster is locked to a position.
@@ -303,6 +341,7 @@ export class Broadcast<T extends BroadcastSource = BroadcastSource> {
303341
this.chat.close();
304342
this.captions.close();
305343
this.name.close();
344+
this.mesh.close();
306345

307346
// NOTE: Don't close the source broadcast; we need it for the local preview.
308347
// this.source.close();

app/src/room/fake.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ export class FakeBroadcast {
126126
video.onloadedmetadata = () => {
127127
this.video.catalog.set([
128128
{
129-
track: "video",
129+
track: { name: "video", priority: 0 },
130130
config: {
131131
codec: "fake",
132132
// Required for the correct display size.
@@ -155,7 +155,7 @@ export class FakeBroadcast {
155155

156156
this.video.catalog.set([
157157
{
158-
track: "image",
158+
track: { name: "image", priority: 0 },
159159
config: {
160160
codec: "fake",
161161
displayAspectWidth: u53(image.width),
Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,7 @@ uniform float u_finalAlpha; // Pre-computed final alpha (0.3 + volume * 0.4)
1414

1515
out vec4 fragColor;
1616

17-
// Signed distance function for rounded rectangle
18-
float roundedBoxSDF(vec2 center, vec2 size, float radius) {
19-
vec2 q = abs(center) - size + radius;
20-
return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - radius;
21-
}
17+
#include "./util/sdf.glsl"
2218

2319
void main() {
2420
if (u_opacity <= 0.01) {
Lines changed: 49 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,15 @@
11
import type { Broadcast } from "../broadcast";
22
import { Canvas } from "../canvas";
3+
import audioFragSource from "./audio.frag";
4+
import audioVertSource from "./audio.vert";
35
import type { Camera } from "./camera";
4-
import outlineFragSource from "./outline.frag?raw";
5-
import outlineVertSource from "./outline.vert?raw";
6+
import type { MeshBuffer } from "./mesh";
67
import { Attribute, Shader, Uniform1f, Uniform2f, Uniform3f, Uniform4f, UniformMatrix4fv } from "./shader";
78

8-
export class OutlineRenderer {
9+
export class AudioRenderer {
910
#canvas: Canvas;
1011
#program: Shader;
11-
#vao: WebGLVertexArrayObject;
12-
#positionBuffer: WebGLBuffer;
13-
#indexBuffer: WebGLBuffer;
12+
#vaos = new Map<MeshBuffer, WebGLVertexArrayObject>();
1413

1514
// Typed uniforms
1615
#u_projection: UniformMatrix4fv;
@@ -24,13 +23,18 @@ export class OutlineRenderer {
2423
#u_color: Uniform3f;
2524
#u_time: Uniform1f;
2625
#u_finalAlpha: Uniform1f;
26+
#u_dragPoint: Uniform2f;
27+
#u_velocity: Uniform2f;
28+
#u_dragStrength: Uniform1f;
29+
#u_zoomDeform: Uniform1f;
30+
#u_zoomCenter: Uniform2f;
2731

2832
// Typed attributes
2933
#a_position: Attribute;
3034

3135
constructor(canvas: Canvas) {
3236
this.#canvas = canvas;
33-
this.#program = new Shader(canvas.gl, outlineVertSource, outlineFragSource);
37+
this.#program = new Shader(canvas.gl, audioVertSource, audioFragSource);
3438

3539
// Initialize typed uniforms
3640
this.#u_projection = this.#program.createUniformMatrix4fv("u_projection");
@@ -44,63 +48,51 @@ export class OutlineRenderer {
4448
this.#u_color = this.#program.createUniform3f("u_color");
4549
this.#u_time = this.#program.createUniform1f("u_time");
4650
this.#u_finalAlpha = this.#program.createUniform1f("u_finalAlpha");
51+
this.#u_dragPoint = this.#program.createUniform2f("u_dragPoint");
52+
this.#u_velocity = this.#program.createUniform2f("u_velocity");
53+
this.#u_dragStrength = this.#program.createUniform1f("u_dragStrength");
54+
this.#u_zoomDeform = this.#program.createUniform1f("u_zoomDeform");
55+
this.#u_zoomCenter = this.#program.createUniform2f("u_zoomCenter");
4756

4857
// Initialize typed attributes
4958
this.#a_position = this.#program.createAttribute("a_position");
50-
51-
const vao = this.#canvas.gl.createVertexArray();
52-
if (!vao) throw new Error("Failed to create VAO");
53-
this.#vao = vao;
54-
55-
const positionBuffer = this.#canvas.gl.createBuffer();
56-
if (!positionBuffer) throw new Error("Failed to create position buffer");
57-
this.#positionBuffer = positionBuffer;
58-
59-
const indexBuffer = this.#canvas.gl.createBuffer();
60-
if (!indexBuffer) throw new Error("Failed to create index buffer");
61-
this.#indexBuffer = indexBuffer;
62-
63-
this.#setupBuffers();
6459
}
6560

66-
#setupBuffers() {
67-
const gl = this.#canvas.gl;
68-
69-
// Quad vertices (0-1 range, will be scaled by bounds)
70-
const positions = new Float32Array([
71-
0,
72-
0, // Top-left
73-
1,
74-
0, // Top-right
75-
1,
76-
1, // Bottom-right
77-
0,
78-
1, // Bottom-left
79-
]);
61+
#getOrCreateVAO(mesh: MeshBuffer): WebGLVertexArrayObject {
62+
let vao = this.#vaos.get(mesh);
63+
if (vao) return vao;
8064

81-
// Indices for two triangles
82-
const indices = new Uint16Array([0, 1, 2, 0, 2, 3]);
65+
const gl = this.#canvas.gl;
66+
vao = gl.createVertexArray();
67+
if (!vao) throw new Error("Failed to create VAO");
8368

84-
gl.bindVertexArray(this.#vao);
69+
gl.bindVertexArray(vao);
8570

8671
// Position attribute
87-
gl.bindBuffer(gl.ARRAY_BUFFER, this.#positionBuffer);
88-
gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW);
72+
gl.bindBuffer(gl.ARRAY_BUFFER, mesh.positionBuffer);
8973
gl.enableVertexAttribArray(this.#a_position.location);
9074
gl.vertexAttribPointer(this.#a_position.location, 2, gl.FLOAT, false, 0, 0);
9175

9276
// Index buffer
93-
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.#indexBuffer);
94-
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);
77+
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, mesh.indexBuffer);
9578

9679
gl.bindVertexArray(null);
80+
81+
this.#vaos.set(mesh, vao);
82+
return vao;
9783
}
9884

9985
render(broadcast: Broadcast, camera: Camera, maxZ: number, now: DOMHighResTimeStamp) {
10086
const gl = this.#canvas.gl;
10187
const bounds = broadcast.bounds.peek();
10288
const scale = broadcast.zoom.peek();
10389
const volume = broadcast.audio.volume;
90+
const dragPoint = broadcast.dragPoint.peek();
91+
const deformVelocity = broadcast.deformVelocity;
92+
const zoomCenter = broadcast.zoomCenter.peek();
93+
94+
// Get or create VAO for this broadcast's mesh
95+
const vao = this.#getOrCreateVAO(broadcast.mesh);
10496

10597
this.#program.use();
10698

@@ -180,17 +172,27 @@ export class OutlineRenderer {
180172

181173
this.#u_color.set(r, g, b);
182174

175+
// Set drag deformation uniforms (using deformVelocity which decays separately)
176+
this.#u_dragPoint.set(dragPoint.x, dragPoint.y);
177+
this.#u_velocity.set(deformVelocity.x, deformVelocity.y);
178+
this.#u_dragStrength.set(0.5); // Halved for subtler effect
179+
180+
// Set zoom deformation uniforms
181+
this.#u_zoomDeform.set(broadcast.zoomDeform);
182+
this.#u_zoomCenter.set(zoomCenter.x, zoomCenter.y);
183+
183184
// Draw
184-
gl.bindVertexArray(this.#vao);
185-
gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0);
185+
gl.bindVertexArray(vao);
186+
gl.drawElements(gl.TRIANGLES, broadcast.mesh.indexCount, gl.UNSIGNED_SHORT, 0);
186187
gl.bindVertexArray(null);
187188
}
188189

189190
close() {
190191
const gl = this.#canvas.gl;
191-
gl.deleteVertexArray(this.#vao);
192-
gl.deleteBuffer(this.#positionBuffer);
193-
gl.deleteBuffer(this.#indexBuffer);
192+
for (const vao of this.#vaos.values()) {
193+
gl.deleteVertexArray(vao);
194+
}
195+
this.#vaos.clear();
194196
this.#program.cleanup();
195197
}
196198
}

app/src/room/gl/audio.vert

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
#version 300 es
2+
precision highp float;
3+
4+
in vec2 a_position;
5+
6+
uniform mat4 u_projection;
7+
uniform vec4 u_bounds; // x, y, width, height
8+
uniform float u_depth;
9+
uniform vec2 u_dragPoint; // Normalized drag point (0-1) relative to broadcast
10+
uniform vec2 u_velocity; // Current velocity vector
11+
uniform float u_dragStrength; // Strength multiplier for drag effect
12+
uniform float u_zoomDeform; // Zoom deformation (positive = expanding, negative = contracting)
13+
uniform vec2 u_zoomCenter; // Normalized zoom center (0-1) relative to broadcast
14+
15+
out vec2 v_pos; // Position within the quad (0-1)
16+
17+
#include "./deformation.glsl"
18+
19+
void main() {
20+
// Apply deformation using shared function
21+
vec2 pos = applyDeformation(
22+
a_position,
23+
u_dragPoint,
24+
u_velocity,
25+
u_dragStrength,
26+
u_zoomDeform,
27+
u_zoomCenter,
28+
u_bounds
29+
);
30+
31+
// Apply projection
32+
gl_Position = u_projection * vec4(pos, u_depth, 1.0);
33+
34+
v_pos = a_position;
35+
}

app/src/room/gl/background.frag

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -14,28 +14,13 @@ const float WOBBLE_SPEED = 0.0004;
1414

1515
const float SEGMENT_WIDTH = 120.0; // Pixels per segment
1616

17+
#include "./util/color.glsl"
18+
1719
// Hash function for deterministic randomness
1820
float hash(float n) {
1921
return fract(sin(n) * 43758.5453123);
2022
}
2123

22-
// Convert HSL to RGB
23-
vec3 hsl2rgb(float h, float s, float l) {
24-
float c = (1.0 - abs(2.0 * l - 1.0)) * s;
25-
float x = c * (1.0 - abs(mod(h / 60.0, 2.0) - 1.0));
26-
float m = l - c / 2.0;
27-
28-
vec3 rgb;
29-
if (h < 60.0) rgb = vec3(c, x, 0.0);
30-
else if (h < 120.0) rgb = vec3(x, c, 0.0);
31-
else if (h < 180.0) rgb = vec3(0.0, c, x);
32-
else if (h < 240.0) rgb = vec3(0.0, x, c);
33-
else if (h < 300.0) rgb = vec3(x, 0.0, c);
34-
else rgb = vec3(c, 0.0, x);
35-
36-
return rgb + m;
37-
}
38-
3924
void main() {
4025
// Work in simple horizontal line space - rotation happens in vertex shader
4126
vec2 pos = v_pixel;

0 commit comments

Comments
 (0)