@bigmistqke/view.gl 0.1.11 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,877 @@
1
+ import { compile, glsl, uniform } from '../../../src/tag.ts'
2
+ import { GLSL, GLSLToView } from '../../../src/types.ts'
3
+ import { dom } from '../utils.ts'
4
+
5
+ const NUM_SPHERES = 8
6
+ const NUM_BOXES = 3
7
+ const NUM_TORI = 4
8
+
9
+ // Ray casting quality control
10
+ const RAY_MARCH_ITERATIONS = 129 // Higher = better quality but slower (default: 128)
11
+ const RAY_HIT_THRESHOLD = 0.001 // Lower = better quality but slower (default: 0.001)
12
+ const NORMAL_EPSILON = 0.001 // Lower = more accurate normals (default: 0.001)
13
+
14
+ // Create typed arrays for sphere data and colors
15
+ const SPHERE_FLOAT_ARRAYS = {
16
+ positions: new Float32Array(
17
+ [
18
+ [0, 0, 0, 1.2],
19
+ [1.5, 0.5, -1, 1.0],
20
+ [-1.5, -0.5, 0.5, 1.0],
21
+ [0.5, 1.5, -0.5, 0.9],
22
+ [-1, -1.5, 1, 0.8],
23
+ [2, -1, -1.5, 0.7],
24
+ [-2, 1, 1.5, 0.9],
25
+ [0, -0.5, -2, 1.0],
26
+ ].flat(),
27
+ ),
28
+ colors: new Float32Array(
29
+ [
30
+ [1.0, 0.3, 0.3], // Red
31
+ [0.3, 1.0, 0.3], // Green
32
+ [0.3, 0.3, 1.0], // Blue
33
+ [1.0, 1.0, 0.3], // Yellow
34
+ [1.0, 0.3, 1.0], // Magenta
35
+ [0.3, 1.0, 1.0], // Cyan
36
+ [1.0, 0.7, 0.3], // Orange
37
+ [0.7, 0.3, 1.0], // Purple
38
+ ].flat(),
39
+ ),
40
+ }
41
+
42
+ // Create typed arrays for box data and colors
43
+ const BOX_FLOAT_ARRAYS = {
44
+ positions: new Float32Array(
45
+ [
46
+ [1, 0, -0.5, 0.9],
47
+ [-1, 0, 0.5, 0.8],
48
+ [0, -1, -1, 0.9],
49
+ ].flat(),
50
+ ),
51
+ colors: new Float32Array(
52
+ [
53
+ [0.8, 0.8, 0.2], // Yellow-green
54
+ [0.2, 0.8, 0.8], // Cyan
55
+ [0.8, 0.2, 0.8], // Magenta
56
+ ].flat(),
57
+ ),
58
+ }
59
+
60
+ // Create typed arrays for torus data and colors
61
+ const TORUS_FLOAT_ARRAYS = {
62
+ positions: new Float32Array(
63
+ [
64
+ [0, 1.5, 0],
65
+ [-1.5, 0, 0],
66
+ [1.5, -0.5, -0.5],
67
+ [0, -1.5, 1],
68
+ ].flat(),
69
+ ),
70
+ radii: new Float32Array(
71
+ [
72
+ [0.8, 0.3],
73
+ [0.6, 0.2],
74
+ [0.7, 0.25],
75
+ [0.5, 0.2],
76
+ ].flat(),
77
+ ),
78
+ colors: new Float32Array(
79
+ [
80
+ [0.9, 0.5, 0.2], // Orange
81
+ [0.2, 0.9, 0.5], // Green
82
+ [0.5, 0.2, 0.9], // Purple
83
+ [0.9, 0.9, 0.2], // Yellow
84
+ ].flat(),
85
+ ),
86
+ }
87
+
88
+ let SHAPE_COUNTER = 0
89
+
90
+ class Camera {
91
+ private theta = 0 // Horizontal angle
92
+ private phi = Math.PI / 4 // Vertical angle
93
+ private targetTheta = 0
94
+ private targetPhi = Math.PI / 4
95
+ radius = 6
96
+
97
+ private isDragging = false
98
+ private lastX = 0
99
+ private lastY = 0
100
+
101
+ private position = [0, 0, 0] as [number, number, number]
102
+ private direction = [0, 0, -1] as [number, number, number]
103
+ private up = [0, 1, 0] as [number, number, number]
104
+ private right = [1, 0, 0] as [number, number, number]
105
+
106
+ template = glsl`
107
+ ${uniform.vec3('u_cameraPos')}
108
+ ${uniform.vec3('u_cameraDir')}
109
+ ${uniform.vec3('u_cameraUp')}
110
+ ${uniform.vec3('u_cameraRight')}`
111
+
112
+ view?: GLSLToView<typeof this.template>
113
+
114
+ startDrag(x: number, y: number) {
115
+ this.isDragging = true
116
+ this.lastX = x
117
+ this.lastY = y
118
+ }
119
+
120
+ drag(x: number, y: number) {
121
+ if (!this.isDragging) return
122
+
123
+ const deltaX = x - this.lastX
124
+ const deltaY = y - this.lastY
125
+
126
+ this.targetTheta += deltaX * 0.01
127
+ this.targetPhi = Math.max(0.1, Math.min(Math.PI - 0.1, this.targetPhi - deltaY * 0.01))
128
+
129
+ this.lastX = x
130
+ this.lastY = y
131
+ }
132
+
133
+ endDrag() {
134
+ this.isDragging = false
135
+ }
136
+
137
+ zoom(delta: number) {
138
+ this.radius = Math.max(2, Math.min(15, this.radius + delta * 0.01))
139
+ }
140
+
141
+ update() {
142
+ // Smooth camera rotation
143
+ this.theta += (this.targetTheta - this.theta) * 0.1
144
+ this.phi += (this.targetPhi - this.phi) * 0.1
145
+
146
+ // Calculate camera position from spherical coordinates
147
+ const x = this.radius * Math.sin(this.phi) * Math.cos(this.theta)
148
+ const y = this.radius * Math.cos(this.phi)
149
+ const z = this.radius * Math.sin(this.phi) * Math.sin(this.theta)
150
+
151
+ this.position = [x, y, z]
152
+ const target = [0, 0, 0] as const
153
+
154
+ // Calculate camera direction (looking at origin)
155
+ const dir = [
156
+ target[0] - this.position[0],
157
+ target[1] - this.position[1],
158
+ target[2] - this.position[2],
159
+ ] as const
160
+ const dirLength = Math.sqrt(dir[0] ** 2 + dir[1] ** 2 + dir[2] ** 2)
161
+ this.direction = [dir[0] / dirLength, dir[1] / dirLength, dir[2] / dirLength]
162
+
163
+ // Calculate up vector
164
+ const up = [0, 1, 0] as const
165
+
166
+ // Calculate right vector (cross product of dir and up)
167
+ this.right = [
168
+ this.direction[1] * up[2] - this.direction[2] * up[1],
169
+ this.direction[2] * up[0] - this.direction[0] * up[2],
170
+ this.direction[0] * up[1] - this.direction[1] * up[0],
171
+ ] as const
172
+
173
+ // Recalculate up vector (cross product of right and dir)
174
+ this.up = [
175
+ this.right[1] * this.direction[2] - this.right[2] * this.direction[1],
176
+ this.right[2] * this.direction[0] - this.right[0] * this.direction[2],
177
+ this.right[0] * this.direction[1] - this.right[1] * this.direction[0],
178
+ ] as const
179
+
180
+ // Update uniforms
181
+ if (this.view) {
182
+ this.view.uniforms.u_cameraPos.set(this.position[0], this.position[1], this.position[2])
183
+ this.view.uniforms.u_cameraDir.set(this.direction[0], this.direction[1], this.direction[2])
184
+ this.view.uniforms.u_cameraUp.set(this.up[0], this.up[1], this.up[2])
185
+ this.view.uniforms.u_cameraRight.set(this.right[0], this.right[1], this.right[2])
186
+ }
187
+ }
188
+ }
189
+
190
+ const controller = new AbortController()
191
+ const canvas = dom('canvas', { parentElement: document.body })
192
+
193
+ const gl = canvas.getContext('webgl2')!
194
+
195
+ // Blending parameter
196
+ let blendFactor = 0.8
197
+
198
+ abstract class Shape {
199
+ id = SHAPE_COUNTER++
200
+ abstract template: GLSL
201
+ view?: GLSLToView<typeof this.template>
202
+
203
+ calculateIntersection = Symbol()
204
+ calculateColor = Symbol()
205
+ calculateSDF = Symbol()
206
+ u_data = Symbol()
207
+ u_colors = Symbol()
208
+ u_positions = Symbol()
209
+ u_radii = Symbol()
210
+
211
+ setData(data: Float32Array) {
212
+ this.view?.uniforms[this.u_data]?.set(data)
213
+ return this
214
+ }
215
+ setColors(data: Float32Array) {
216
+ this.view?.uniforms[this.u_colors]?.set(data)
217
+ return this
218
+ }
219
+ }
220
+
221
+ class Sphere extends Shape {
222
+ sphereIntersection = Symbol()
223
+ sphereNormal = Symbol()
224
+ sphereSDF = Symbol()
225
+ template = glsl`
226
+ struct Sphere {
227
+ vec3 position;
228
+ float radius;
229
+ vec3 color;
230
+ };
231
+
232
+ ${uniform.vec4(this.u_data, { size: NUM_SPHERES })}
233
+ ${uniform.vec3(this.u_colors, { size: NUM_SPHERES })}
234
+
235
+ float ${this.sphereIntersection}(in vec3 rayOrigin, in vec3 rayDirection, in Sphere sphere)
236
+ {
237
+ // We're shooting a ray from the camera and checking if it hits a sphere
238
+ // Think of it like shining a laser pointer at a ball
239
+
240
+ vec3 rayToSphere = rayOrigin - sphere.position;
241
+
242
+ // Step 1: Find the closest point on the ray to the sphere's position
243
+ // This is like finding where the laser beam passes nearest to the ball's position
244
+ float closestApproach = dot(rayToSphere, rayDirection);
245
+
246
+ // Step 2: Check if we're already inside the sphere
247
+ // If negative, we're outside and pointing away from sphere position
248
+ float rayStartDistanceSquared = dot(rayToSphere, rayToSphere) - sphere.radius * sphere.radius;
249
+
250
+ // Step 3: Use geometry to check if the ray can reach the sphere
251
+ // If this value is negative, the ray misses the sphere entirely
252
+ float canReachSphere = closestApproach * closestApproach - rayStartDistanceSquared;
253
+
254
+ if(canReachSphere < 0.0) {
255
+ return -1.0; // Ray misses the sphere
256
+ }
257
+
258
+ // Step 4: Calculate how far along the ray we hit the sphere
259
+ // We want the nearest intersection point (there can be two when ray goes through)
260
+ float hitDistance = -closestApproach - sqrt(canReachSphere);
261
+
262
+ return hitDistance;
263
+ }
264
+
265
+ // Calculate normal vector at hit point on sphere surface
266
+ vec3 ${this.sphereNormal}(in vec3 hitPosition, in Sphere sphere)
267
+ {
268
+ return (hitPosition - sphere.position) / sphere.radius;
269
+ }
270
+
271
+ // Find closest sphere intersection, returns distance and sets hitIndex and hitType
272
+ float ${this.calculateIntersection}(in vec3 rayOrigin, in vec3 rayDirection, in float maxDistance, out int hitIndex, out int hitType)
273
+ {
274
+ float closestDistance = maxDistance;
275
+ hitIndex = -1;
276
+ hitType = -1;
277
+
278
+ for(int i = 0; i < ${NUM_SPHERES}; i++) {
279
+ Sphere sphere = Sphere(${this.u_data}[i].xyz, ${this.u_data}[i].w, ${this.u_colors}[i]);
280
+ float distance = ${this.sphereIntersection}(rayOrigin, rayDirection, sphere);
281
+
282
+ if(distance > 0.0 && distance < closestDistance) {
283
+ closestDistance = distance;
284
+ hitIndex = i;
285
+ hitType = ${this.id};
286
+ }
287
+ }
288
+
289
+ return hitIndex >= 0 ? closestDistance : -1.0;
290
+ }
291
+
292
+ // Calculate sphere normal and color at hit point
293
+ void ${this.calculateColor}(in vec3 hitPosition, in int hitIndex, out vec3 normal, out vec3 color)
294
+ {
295
+ Sphere hitSphere = Sphere(${this.u_data}[hitIndex].xyz, ${this.u_data}[hitIndex].w, ${this.u_colors}[hitIndex]);
296
+ normal = ${this.sphereNormal}(hitPosition, hitSphere);
297
+ color = ${this.u_colors}[hitIndex];
298
+ }
299
+
300
+ // Signed distance function for sphere
301
+ float ${this.sphereSDF}(in vec3 point, in vec3 center, in float radius)
302
+ {
303
+ return length(point - center) - radius;
304
+ }
305
+
306
+ // Calculate SDF for all spheres
307
+ float ${this.calculateSDF}(in vec3 point, out int nearestIndex, out float minDistance)
308
+ {
309
+ minDistance = 1000.0;
310
+ nearestIndex = -1;
311
+
312
+ for(int i = 0; i < ${NUM_SPHERES}; i++) {
313
+ float distance = ${this.sphereSDF}(point, ${this.u_data}[i].xyz, ${this.u_data}[i].w);
314
+
315
+ if(distance < minDistance) {
316
+ minDistance = distance;
317
+ nearestIndex = i;
318
+ }
319
+ }
320
+
321
+ return minDistance;
322
+ }`
323
+ }
324
+
325
+ class Box extends Shape {
326
+ boxIntersection = Symbol()
327
+ boxNormal = Symbol()
328
+ boxSDF = Symbol()
329
+ template = glsl`
330
+
331
+ #define NUM_BOXES ${NUM_BOXES}
332
+
333
+ struct Box {
334
+ vec3 position;
335
+ float size;
336
+ vec3 color;
337
+ };
338
+
339
+ ${uniform.vec4(this.u_data, { size: NUM_BOXES })}
340
+ ${uniform.vec3(this.u_colors, { size: NUM_BOXES })}
341
+
342
+ float ${this.boxIntersection}(in vec3 rayOrigin, in vec3 rayDirection, in Box box)
343
+ {
344
+ // Ray-box intersection using slab method
345
+ // A box is defined by 6 planes, we find where the ray enters and exits each pair of parallel planes
346
+
347
+ vec3 boxMin = box.position - vec3(box.size);
348
+ vec3 boxMax = box.position + vec3(box.size);
349
+
350
+ // Calculate intersection distances for each axis
351
+ vec3 invDir = 1.0 / rayDirection;
352
+ vec3 t1 = (boxMin - rayOrigin) * invDir;
353
+ vec3 t2 = (boxMax - rayOrigin) * invDir;
354
+
355
+ // Find the min and max for each axis (handles negative direction)
356
+ vec3 tMin = min(t1, t2);
357
+ vec3 tMax = max(t1, t2);
358
+
359
+ // The ray enters the box at the maximum of all tMin values
360
+ float tNear = max(max(tMin.x, tMin.y), tMin.z);
361
+
362
+ // The ray exits the box at the minimum of all tMax values
363
+ float tFar = min(min(tMax.x, tMax.y), tMax.z);
364
+
365
+ // If tNear > tFar, ray misses the box
366
+ // If tFar < 0, box is behind the ray
367
+ if(tNear > tFar || tFar < 0.0) {
368
+ return -1.0;
369
+ }
370
+
371
+ // Return the nearest intersection (tNear, unless we're inside the box)
372
+ return tNear > 0.0 ? tNear : tFar;
373
+ }
374
+
375
+ // Calculate normal vector at hit point on box surface
376
+ vec3 ${this.boxNormal}(in vec3 hitPosition, in Box box)
377
+ {
378
+ // Find which face of the box we hit by checking which coordinate is closest to the box boundary
379
+ vec3 localPos = hitPosition - box.position;
380
+ vec3 absLocal = abs(localPos);
381
+
382
+ // Return normal for the face that's closest to the box size
383
+ if(absLocal.x > absLocal.y && absLocal.x > absLocal.z) {
384
+ return vec3(sign(localPos.x), 0.0, 0.0);
385
+ } else if(absLocal.y > absLocal.z) {
386
+ return vec3(0.0, sign(localPos.y), 0.0);
387
+ } else {
388
+ return vec3(0.0, 0.0, sign(localPos.z));
389
+ }
390
+ }
391
+
392
+ // Find closest box intersection, returns distance and sets hitIndex and hitType
393
+ float ${this.calculateIntersection}(in vec3 rayOrigin, in vec3 rayDirection, in float maxDistance, out int hitIndex, out int hitType)
394
+ {
395
+ float closestDistance = maxDistance;
396
+ hitIndex = -1;
397
+ hitType = -1;
398
+
399
+ for(int i = 0; i < ${NUM_BOXES}; i++) {
400
+ Box box = Box(${this.u_data}[i].xyz, ${this.u_data}[i].w, ${this.u_colors}[i]);
401
+ float distance = ${this.boxIntersection}(rayOrigin, rayDirection, box);
402
+
403
+ if(distance > 0.0 && distance < closestDistance) {
404
+ closestDistance = distance;
405
+ hitIndex = i;
406
+ hitType = ${this.id};
407
+ }
408
+ }
409
+
410
+ return hitIndex >= 0 ? closestDistance : -1.0;
411
+ }
412
+
413
+ // Calculate box normal and color at hit point
414
+ void ${this.calculateColor}(in vec3 hitPosition, in int hitIndex, out vec3 normal, out vec3 color)
415
+ {
416
+ Box hitBox = Box(${this.u_data}[hitIndex].xyz, ${this.u_data}[hitIndex].w, ${this.u_colors}[hitIndex]);
417
+ normal = ${this.boxNormal}(hitPosition, hitBox);
418
+ color = ${this.u_colors}[hitIndex];
419
+ }
420
+
421
+ // Signed distance function for box
422
+ float ${this.boxSDF}(in vec3 point, in vec3 center, in float size)
423
+ {
424
+ vec3 d = abs(point - center) - vec3(size);
425
+ return length(max(d, vec3(0.0))) + min(max(d.x, max(d.y, d.z)), 0.0);
426
+ }
427
+
428
+ // Calculate SDF for all boxes
429
+ float ${this.calculateSDF}(in vec3 point, out int nearestIndex, out float minDistance)
430
+ {
431
+ minDistance = 1000.0;
432
+ nearestIndex = -1;
433
+
434
+ for(int i = 0; i < ${NUM_BOXES}; i++) {
435
+ float distance = ${this.boxSDF}(point, ${this.u_data}[i].xyz, ${this.u_data}[i].w);
436
+
437
+ if(distance < minDistance) {
438
+ minDistance = distance;
439
+ nearestIndex = i;
440
+ }
441
+ }
442
+
443
+ return minDistance;
444
+ }
445
+ `
446
+ }
447
+
448
+ class Torus extends Shape {
449
+ torusIntersection = Symbol()
450
+ torusNormal = Symbol()
451
+ torusSDF = Symbol()
452
+
453
+ setData(positions: Float32Array, radii: Float32Array) {
454
+ this.view?.uniforms[this.u_positions]?.set(positions)
455
+ this.view?.uniforms[this.u_radii]?.set(radii)
456
+ return this
457
+ }
458
+
459
+ template = glsl`
460
+
461
+ #define NUM_TORI ${NUM_TORI}
462
+
463
+ struct Torus {
464
+ vec3 position;
465
+ float majorRadius;
466
+ float minorRadius;
467
+ vec3 color;
468
+ };
469
+
470
+ ${uniform.vec3(this.u_positions, { size: NUM_TORI })}
471
+ ${uniform.vec2(this.u_radii, { size: NUM_TORI })}
472
+ ${uniform.vec3(this.u_colors, { size: NUM_TORI })}
473
+
474
+ // Signed distance function for torus
475
+ float ${this.torusSDF}(in vec3 point, in vec3 center, in float majorRadius, in float minorRadius)
476
+ {
477
+ vec3 p = point - center;
478
+ vec2 q = vec2(length(p.xz) - majorRadius, p.y);
479
+ return length(q) - minorRadius;
480
+ }
481
+
482
+ // Calculate SDF for all tori
483
+ float ${this.calculateSDF}(in vec3 point, out int nearestIndex, out float minDistance)
484
+ {
485
+ minDistance = 1000.0;
486
+ nearestIndex = -1;
487
+
488
+ for(int i = 0; i < ${NUM_TORI}; i++) {
489
+ float distance = ${this.torusSDF}(point, ${this.u_positions}[i], ${this.u_radii}[i].x, ${this.u_radii}[i].y);
490
+
491
+ if(distance < minDistance) {
492
+ minDistance = distance;
493
+ nearestIndex = i;
494
+ }
495
+ }
496
+
497
+ return minDistance;
498
+ }
499
+
500
+ // Torus intersection for fallback rendering
501
+ float ${this.torusIntersection}(in vec3 rayOrigin, in vec3 rayDirection, in Torus torus)
502
+ {
503
+ // Simplified intersection - not exact but good enough for fallback
504
+ return ${this.torusSDF}(rayOrigin, torus.position, torus.majorRadius, torus.minorRadius);
505
+ }
506
+
507
+ // Calculate torus normal
508
+ vec3 ${this.torusNormal}(in vec3 hitPosition, in Torus torus)
509
+ {
510
+ vec3 p = hitPosition - torus.position;
511
+ float len = length(vec2(p.x, p.z));
512
+ vec3 d = vec3(p.x, 0.0, p.z) / len;
513
+ return normalize(p - d * torus.majorRadius);
514
+ }
515
+
516
+ // Find closest torus intersection
517
+ float ${this.calculateIntersection}(in vec3 rayOrigin, in vec3 rayDirection, in float maxDistance, out int hitIndex, out int hitType)
518
+ {
519
+ float closestDistance = maxDistance;
520
+ hitIndex = -1;
521
+ hitType = -1;
522
+
523
+ for(int i = 0; i < ${NUM_TORI}; i++) {
524
+ Torus torus = Torus(${this.u_positions}[i], ${this.u_radii}[i].x, ${this.u_radii}[i].y, ${this.u_colors}[i]);
525
+ float distance = ${this.torusIntersection}(rayOrigin, rayDirection, torus);
526
+
527
+ if(distance > 0.0 && distance < closestDistance) {
528
+ closestDistance = distance;
529
+ hitIndex = i;
530
+ hitType = ${this.id};
531
+ }
532
+ }
533
+
534
+ return hitIndex >= 0 ? closestDistance : -1.0;
535
+ }
536
+
537
+ // Calculate torus normal and color at hit point
538
+ void ${this.calculateColor}(in vec3 hitPosition, in int hitIndex, out vec3 normal, out vec3 color)
539
+ {
540
+ Torus hitTorus = Torus(${this.u_positions}[hitIndex], ${this.u_radii}[hitIndex].x, ${this.u_radii}[hitIndex].y, ${this.u_colors}[hitIndex]);
541
+ normal = ${this.torusNormal}(hitPosition, hitTorus);
542
+ color = ${this.u_colors}[hitIndex];
543
+ }
544
+ `
545
+ }
546
+
547
+ const box = new Box()
548
+ const sphere = new Sphere()
549
+ const torus = new Torus()
550
+ const camera = new Camera()
551
+
552
+ const shapes = [box, sphere, torus]
553
+
554
+ const { program, view } = compile.toQuad(
555
+ gl,
556
+ glsl`#version 300 es
557
+ precision mediump float;
558
+
559
+ struct HitInfo {
560
+ bool hit;
561
+ float distance;
562
+ vec3 position;
563
+ vec3 normal;
564
+ vec3 color;
565
+ };
566
+
567
+ ${shapes.map(shape => shape.template)}
568
+
569
+ ${camera.template}
570
+ ${uniform.float('aspectRatio')}
571
+ ${uniform.float('u_blendFactor')}
572
+
573
+ in vec2 v_uv;
574
+ out vec4 fragColor;
575
+
576
+ vec3 lightPos = vec3(5.0, 5.0, 5.0);
577
+
578
+ // Smooth minimum function for blending SDFs
579
+ float smoothMin(float a, float b, float k) {
580
+ float h = max(k - abs(a - b), 0.0);
581
+ return min(a, b) - h * h * 0.25 / k;
582
+ }
583
+
584
+ // Structure to hold shape contribution info
585
+ struct ShapeContribution {
586
+ float distance;
587
+ int shapeType;
588
+ int index;
589
+ float weight;
590
+ };
591
+
592
+ // Combined SDF function that blends all shapes and calculates color weights
593
+ float combinedSDF(in vec3 point, out vec3 blendedColor)
594
+ {
595
+ ShapeContribution shapes[${shapes.length}];
596
+ float totalWeight = 0.0;
597
+ blendedColor = vec3(0.0);
598
+
599
+ ${shapes.map((shape, i) => {
600
+ const index = Symbol()
601
+ const minDist = Symbol()
602
+ return glsl`
603
+ int ${index};
604
+ float ${minDist};
605
+ shapes[${i}].distance = ${shape.calculateSDF}(point, ${index}, ${minDist});
606
+ shapes[${i}].shapeType = ${shape.id};
607
+ shapes[${i}].index = ${index};
608
+ `
609
+ })}
610
+
611
+ // Calculate blended distance using smooth minimum
612
+ float blendedDist = shapes[0].distance;
613
+ for(int i = 1; i < ${shapes.length}; i++) {
614
+ blendedDist = smoothMin(blendedDist, shapes[i].distance, u_blendFactor);
615
+ }
616
+
617
+ // Calculate weights based on how close each shape is to the blended surface
618
+ for(int i = 0; i < ${shapes.length}; i++) {
619
+ float diff = abs(shapes[i].distance - blendedDist);
620
+ // Use exponential falloff with sharper cutoff for more vibrant colors
621
+ shapes[i].weight = exp(-diff * 20.0 / u_blendFactor);
622
+ totalWeight += shapes[i].weight;
623
+ }
624
+
625
+ // Normalize weights and blend colors
626
+ if(totalWeight > 0.0) {
627
+ ${shapes.map(
628
+ (shape, i) => glsl`
629
+ if(shapes[${i}].index >= 0) {
630
+ vec3 shapeColor = ${shape.u_colors}[shapes[${i}].index];
631
+ blendedColor += shapeColor * (shapes[${i}].weight / totalWeight);
632
+ }`,
633
+ )}
634
+ }
635
+
636
+ return blendedDist;
637
+ }
638
+
639
+ // Calculate normal using gradient of SDF
640
+ vec3 calculateSDFNormal(in vec3 point) {
641
+ const float eps = ${NORMAL_EPSILON};
642
+ vec3 dummyColor;
643
+
644
+ float center = combinedSDF(point, dummyColor);
645
+ float dx = combinedSDF(point + vec3(eps, 0.0, 0.0), dummyColor) - center;
646
+ float dy = combinedSDF(point + vec3(0.0, eps, 0.0), dummyColor) - center;
647
+ float dz = combinedSDF(point + vec3(0.0, 0.0, eps), dummyColor) - center;
648
+
649
+ return normalize(vec3(dx, dy, dz));
650
+ }
651
+
652
+ vec3 calculateLighting(in vec3 position, in vec3 normal, in vec3 color)
653
+ {
654
+ vec3 lightDirection = normalize(lightPos - position);
655
+ float diffuse = max(0.0, dot(normal, lightDirection));
656
+ return color * (diffuse * 0.8 + 0.2); // 80% diffuse + 20% ambient
657
+ }
658
+
659
+ // Raymarching with SDF
660
+ bool raymarch(in vec3 rayOrigin, in vec3 rayDirection, out vec3 hitPosition, out vec3 hitColor)
661
+ {
662
+ float t = 0.0;
663
+ for(int i = 0; i < ${RAY_MARCH_ITERATIONS}; i++) {
664
+ hitPosition = rayOrigin + t * rayDirection;
665
+ float dist = combinedSDF(hitPosition, hitColor);
666
+
667
+ if(dist < ${RAY_HIT_THRESHOLD}) {
668
+ return true;
669
+ }
670
+
671
+ t += dist;
672
+
673
+ if(t > 100.0) {
674
+ break;
675
+ }
676
+ }
677
+ return false;
678
+ }
679
+
680
+ void main()
681
+ {
682
+ // Ray from camera through pixel using camera vectors
683
+ vec3 rayOrigin = u_cameraPos;
684
+ vec3 rayDirection = normalize(u_cameraDir + v_uv.x * u_cameraRight * aspectRatio + v_uv.y * u_cameraUp);
685
+
686
+ vec3 color = vec3(0.1, 0.1, 0.2); // Background color
687
+
688
+ vec3 hitPosition;
689
+ vec3 hitColor;
690
+
691
+ if(raymarch(rayOrigin, rayDirection, hitPosition, hitColor)) {
692
+ // Calculate normal using SDF gradient
693
+ vec3 normal = calculateSDFNormal(hitPosition);
694
+
695
+ // Use the blended color from raymarch
696
+ color = calculateLighting(hitPosition, normal, hitColor);
697
+ }
698
+
699
+ // Gamma correction
700
+ // color = pow(color, vec3(1.0 / 2.2));
701
+
702
+ fragColor = vec4(color, 1.0);
703
+ }`,
704
+ )
705
+
706
+ gl.useProgram(program)
707
+
708
+ // Set view for all shapes
709
+ shapes.forEach(shape => (shape.view = view))
710
+
711
+ // Bind quad buffer
712
+ view.attributes.a_quad.bind()
713
+
714
+ sphere.setData(SPHERE_FLOAT_ARRAYS.positions).setColors(SPHERE_FLOAT_ARRAYS.colors)
715
+ box.setData(BOX_FLOAT_ARRAYS.positions).setColors(BOX_FLOAT_ARRAYS.colors)
716
+ torus
717
+ .setData(TORUS_FLOAT_ARRAYS.positions, TORUS_FLOAT_ARRAYS.radii)
718
+ .setColors(TORUS_FLOAT_ARRAYS.colors)
719
+
720
+ // Set initial aspect ratio
721
+ view.uniforms.aspectRatio.set(canvas.width / canvas.height)
722
+ view.uniforms.u_blendFactor.set(blendFactor)
723
+
724
+ // Set view for camera
725
+ camera.view = view
726
+ camera.update()
727
+
728
+ function animate(timestamp: number) {
729
+ const time = timestamp / 1000
730
+
731
+ // Make the center sphere pulse
732
+ const pulseFactor = 1.0 + 0.3 * Math.sin(time * 2)
733
+ SPHERE_FLOAT_ARRAYS.positions[3] = pulseFactor // Update radius of sphere 0
734
+
735
+ // Orbit some spheres - smaller radius for more overlap
736
+ const orbitRadius = 1.5
737
+
738
+ // Update sphere 1 - slower movement
739
+ SPHERE_FLOAT_ARRAYS.positions[4] = 1.5 + Math.cos(time * 0.4) * orbitRadius * 0.5
740
+ SPHERE_FLOAT_ARRAYS.positions[5] = 0.5 + Math.sin(time * 0.6) * 0.8
741
+ SPHERE_FLOAT_ARRAYS.positions[6] = -1 + Math.sin(time * 0.4) * orbitRadius * 0.5
742
+
743
+ // Update sphere 2 - slower movement
744
+ SPHERE_FLOAT_ARRAYS.positions[8] = -1.5 + Math.cos(time * 0.3 + Math.PI) * orbitRadius * 0.5
745
+ SPHERE_FLOAT_ARRAYS.positions[9] = -0.5 + Math.cos(time * 0.5) * 0.6
746
+ SPHERE_FLOAT_ARRAYS.positions[10] = 0.5 + Math.sin(time * 0.3 + Math.PI) * orbitRadius * 0.5
747
+
748
+ // Sphere 3 - gentle vertical motion near boxes
749
+ SPHERE_FLOAT_ARRAYS.positions[13] = 1.5 + Math.sin(time * 0.8) * 0.5 // y position
750
+
751
+ // Sphere 4 - small circular motion
752
+ SPHERE_FLOAT_ARRAYS.positions[16] = -1 + Math.sin(time * 0.4) * 0.8 // x
753
+ SPHERE_FLOAT_ARRAYS.positions[17] = -1.5 + Math.sin(time * 0.6) * 0.5 // y
754
+ SPHERE_FLOAT_ARRAYS.positions[18] = 1 + Math.cos(time * 0.4) * 0.3 // z
755
+
756
+ // Sphere 5 - gentle motion + size change
757
+ SPHERE_FLOAT_ARRAYS.positions[20] = 2 + Math.cos(time * 0.3) * 0.7 // x
758
+ SPHERE_FLOAT_ARRAYS.positions[22] = -1.5 + Math.sin(time * 0.3) * 0.5 // z
759
+ SPHERE_FLOAT_ARRAYS.positions[23] = 0.7 + 0.2 * Math.sin(time * 2) // radius
760
+
761
+ // Sphere 6 - slow wave motion
762
+ SPHERE_FLOAT_ARRAYS.positions[24] = -2 + Math.sin(time * 0.4) * 0.5 // x
763
+ SPHERE_FLOAT_ARRAYS.positions[25] = 1 + Math.sin(time * 0.6) * 0.4 // y
764
+
765
+ // Sphere 7 - gentle rotation
766
+ SPHERE_FLOAT_ARRAYS.positions[28] = Math.cos(time * 0.3) * 1.2 // x
767
+ SPHERE_FLOAT_ARRAYS.positions[29] = -0.5 + Math.sin(time * 0.3) * 0.3 // y
768
+
769
+ // Animate colors for more spheres
770
+ SPHERE_FLOAT_ARRAYS.colors[0] = 0.5 + 0.5 * Math.sin(time * 3) // Red channel of sphere 0
771
+ SPHERE_FLOAT_ARRAYS.colors[4] = 0.5 + 0.5 * Math.cos(time * 2) // Green channel of sphere 1
772
+ SPHERE_FLOAT_ARRAYS.colors[9] = 0.5 + 0.5 * Math.sin(time * 2.5) // Blue channel of sphere 3
773
+ SPHERE_FLOAT_ARRAYS.colors[15] = 0.3 + 0.7 * Math.abs(Math.sin(time * 1.8)) // All channels sphere 5 (pulsing cyan)
774
+ SPHERE_FLOAT_ARRAYS.colors[16] = 0.3 + 0.7 * Math.abs(Math.sin(time * 1.8))
775
+ SPHERE_FLOAT_ARRAYS.colors[17] = 0.3 + 0.7 * Math.abs(Math.sin(time * 1.8))
776
+
777
+ // Animate some boxes
778
+ // Box 0 - gentle motion near spheres
779
+ BOX_FLOAT_ARRAYS.positions[0] = 1 + Math.cos(time * 0.35) * 0.3 // x
780
+ BOX_FLOAT_ARRAYS.positions[2] = -0.5 + Math.sin(time * 0.35) * 0.3 // z
781
+
782
+ // Box 1 - scale pulsing
783
+ BOX_FLOAT_ARRAYS.positions[7] = 0.8 + 0.2 * Math.sin(time * 1.5) // size
784
+
785
+ // Box 2 - gentle vertical motion
786
+ BOX_FLOAT_ARRAYS.positions[9] = -1 + Math.sin(time * 0.4) * 0.3 // y
787
+
788
+ // Animate tori
789
+ // Torus 0 - rotate major radius
790
+ TORUS_FLOAT_ARRAYS.radii[0] = 0.8 + 0.2 * Math.sin(time * 1.2) // major radius
791
+
792
+ // Torus 1 - orbit around center
793
+ TORUS_FLOAT_ARRAYS.positions[3] = -1.5 + Math.cos(time * 0.5) * 0.5 // x
794
+ TORUS_FLOAT_ARRAYS.positions[5] = Math.sin(time * 0.5) * 0.5 // z
795
+
796
+ // Torus 2 - vertical motion
797
+ TORUS_FLOAT_ARRAYS.positions[7] = -0.5 + Math.sin(time * 0.7) * 0.3 // y
798
+
799
+ // Torus 3 - pulsing minor radius
800
+ TORUS_FLOAT_ARRAYS.radii[7] = 0.2 + 0.1 * Math.sin(time * 2) // minor radius
801
+
802
+ // Update the uniform arrays with the animated data
803
+ sphere.setData(SPHERE_FLOAT_ARRAYS.positions).setColors(SPHERE_FLOAT_ARRAYS.colors)
804
+ box.setData(BOX_FLOAT_ARRAYS.positions).setColors(BOX_FLOAT_ARRAYS.colors)
805
+ torus
806
+ .setData(TORUS_FLOAT_ARRAYS.positions, TORUS_FLOAT_ARRAYS.radii)
807
+ .setColors(TORUS_FLOAT_ARRAYS.colors)
808
+
809
+ // Animate blend factor with larger variation for more visible blending
810
+ blendFactor = 0.5 + 0.4 * Math.sin(time * 0.3)
811
+ view.uniforms.u_blendFactor.set(blendFactor)
812
+ }
813
+
814
+ // Mouse controls
815
+ canvas.addEventListener('mousedown', e => {
816
+ camera.startDrag(e.clientX, e.clientY)
817
+ })
818
+
819
+ canvas.addEventListener('mousemove', e => {
820
+ camera.drag(e.clientX, e.clientY)
821
+ })
822
+
823
+ canvas.addEventListener('mouseup', () => {
824
+ camera.endDrag()
825
+ })
826
+
827
+ canvas.addEventListener('mouseleave', () => {
828
+ camera.endDrag()
829
+ })
830
+
831
+ // Mouse wheel for zoom
832
+ canvas.addEventListener('wheel', e => {
833
+ e.preventDefault()
834
+ camera.zoom(e.deltaY)
835
+ })
836
+
837
+ // Touch controls
838
+ canvas.addEventListener('touchstart', e => {
839
+ if (e.touches.length === 1) {
840
+ camera.startDrag(e.touches[0].clientX, e.touches[0].clientY)
841
+ }
842
+ })
843
+
844
+ canvas.addEventListener('touchmove', e => {
845
+ if (e.touches.length === 1) {
846
+ camera.drag(e.touches[0].clientX, e.touches[0].clientY)
847
+ }
848
+ })
849
+
850
+ canvas.addEventListener('touchend', () => {
851
+ camera.endDrag()
852
+ })
853
+
854
+ // Animation loop
855
+ function draw(timestamp: number) {
856
+ if (controller.signal.aborted) return
857
+
858
+ camera.update()
859
+ animate(timestamp)
860
+
861
+ gl.drawArrays(gl.TRIANGLES, 0, 6)
862
+
863
+ requestAnimationFrame(draw)
864
+ }
865
+
866
+ requestAnimationFrame(draw)
867
+
868
+ // Handle canvas resizing
869
+ new ResizeObserver(() => {
870
+ canvas.width = window.innerWidth
871
+ canvas.height = window.innerHeight
872
+ gl.viewport(0, 0, canvas.width, canvas.height)
873
+
874
+ // Update aspect ratio uniform
875
+ view.uniforms.aspectRatio.set(canvas.width / canvas.height)
876
+ draw(performance.now())
877
+ }).observe(document.body)