vgpu-render 0.2.0 → 0.3.0

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.
@@ -59,6 +59,8 @@ struct In {
59
59
  @location(12) color: vec4f,
60
60
  /** bit0: filled; bits 8..15: polygon vertex count. */
61
61
  @location(13) flags: u32,
62
+ /** (visible, gap, phase) of a dashed outline in shape-space units; gap 0 disables it. */
63
+ @location(14) dash: vec3f,
62
64
  }
63
65
 
64
66
  struct Out {
@@ -75,6 +77,7 @@ struct Out {
75
77
  @location(7) rotation: f32,
76
78
  @location(8) @interpolate(flat, either) sides: u32,
77
79
  @location(9) @interpolate(flat, either) flags: u32,
80
+ @location(10) dash: vec3f,
78
81
  }
79
82
 
80
83
  fn rotate2(p: vec2f, angle: f32) -> vec2f {
@@ -171,12 +174,17 @@ fn sd_rhombus(p: vec2f, a: f32, b: f32) -> f32 {
171
174
  }
172
175
 
173
176
  /** Sector of at most half a turn: the intersection of the disc and the two edge half-planes. */
174
- fn sd_convex_sector(p: vec2f, radius: f32, start: f32, sweep: f32) -> f32 {
177
+ /**
178
+ * Angular wedge of sweep radians from start: only the two bounding half-planes, no radius.
179
+ * Keeping the disc out of it matters for the complementary wedge below, where a radius term would
180
+ * clamp the opposite side to zero and leave the uncovered wedge of an arc or sector painted.
181
+ */
182
+ fn sd_wedge(p: vec2f, start: f32, sweep: f32) -> f32 {
175
183
  let end = start + sweep;
176
184
  // Inward normals of the two straight edges, in the order the sweep turns.
177
185
  let startEdge = vec2f(sin(start), -cos(start));
178
186
  let endEdge = vec2f(-sin(end), cos(end));
179
- return max(length(p) - radius, max(dot(p, startEdge), dot(p, endEdge)));
187
+ return max(dot(p, startEdge), dot(p, endEdge));
180
188
  }
181
189
 
182
190
  /**
@@ -186,9 +194,9 @@ fn sd_convex_sector(p: vec2f, radius: f32, start: f32, sweep: f32) -> f32 {
186
194
  fn sd_sector(p: vec2f, radius: f32, start: f32, sweep: f32) -> f32 {
187
195
  let disc = length(p) - radius;
188
196
  if (abs(sweep) >= TAU - EPSILON) { return disc; }
189
- if (abs(sweep) <= PI) { return max(disc, sd_convex_sector(p, radius, start, sweep)); }
197
+ if (abs(sweep) <= PI) { return max(disc, sd_wedge(p, start, sweep)); }
190
198
  let complementSweep = select(TAU + sweep, TAU - sweep, sweep > 0.0);
191
- let complement = sd_convex_sector(p, radius, start + sweep, complementSweep);
199
+ let complement = sd_wedge(p, start + sweep, complementSweep);
192
200
  return max(disc, -complement);
193
201
  }
194
202
 
@@ -227,6 +235,256 @@ fn shape_distance(shape: u32, p: vec2f, size: vec2f, vertexB: vec2f, vertexC: ve
227
235
  }
228
236
  }
229
237
 
238
+ /**
239
+ * Perimeter parameter of the point on a rectangle's outline nearest to the sample, measured
240
+ * anticlockwise from the (sx, sy) corner. The stroke only touches pixels within a pixel or two
241
+ * of the outline, so picking the closer of the two candidate edges there is exact.
242
+ */
243
+ fn rect_perimeter(p: vec2f, halfSize: vec2f) -> f32 {
244
+ let sx = halfSize.x;
245
+ let sy = halfSize.y;
246
+ let q = clamp(p, -halfSize, halfSize);
247
+ let overX = abs(p.x) - sx;
248
+ let overY = abs(p.y) - sy;
249
+ if (overY >= overX) {
250
+ if (p.y >= 0.0) { return sx - q.x; }
251
+ return 2.0 * sx + 2.0 * sy + (sx + q.x);
252
+ }
253
+ if (p.x < 0.0) { return 2.0 * sx + (sy - q.y); }
254
+ return 4.0 * sx + 2.0 * sy + (q.y + sy);
255
+ }
256
+
257
+ /**
258
+ * Closed-outline length in shape-space units, or -1 for an open outline (an arc). A closed
259
+ * outline snaps its dash period to a whole number of periods, so the pattern meets itself at the
260
+ * perimeter start instead of jumping by P mod period.
261
+ */
262
+ fn perimeter_total(shape: u32, size: vec2f, vertexB: vec2f, vertexC: vec2f, params: vec4f, sides: u32) -> f32 {
263
+ switch shape {
264
+ case SHAPE_RECT: { return 4.0 * (size.x + size.y); }
265
+ case SHAPE_ROUNDED_RECT: {
266
+ let corner = clamp(params.x, 0.0, min(size.x, size.y));
267
+ let inner = max(size - vec2f(corner), vec2f(0.0));
268
+ return 4.0 * (inner.x + inner.y) + 2.0 * PI * corner;
269
+ }
270
+ case SHAPE_CIRCLE: { return TAU * size.x; }
271
+ case SHAPE_ELLIPSE: { return ellipse_arc(TAU, size); }
272
+ case SHAPE_ARC: { return -1.0; }
273
+ case SHAPE_ANNULUS: { return TAU * (size.x + size.y); }
274
+ case SHAPE_CAPSULE: { return 2.0 * PI * size.x + 4.0 * size.y; }
275
+ case SHAPE_POLYGON: {
276
+ let count = max(sides, 3u);
277
+ return 2.0 * f32(count) * size.x * sin(PI / f32(count));
278
+ }
279
+ case SHAPE_TRIANGLE: {
280
+ return length(vertexB - size) + length(vertexC - vertexB) + length(size - vertexC);
281
+ }
282
+ case SHAPE_RHOMBUS: { return 4.0 * length(vec2f(0.0, size.y) - vec2f(size.x, 0.0)); }
283
+ case SHAPE_SECTOR: { return abs(params.w) * size.x + 2.0 * size.x; }
284
+ case SHAPE_SEGMENT: {
285
+ let sweepAbs = abs(params.w);
286
+ return sweepAbs * size.x + 2.0 * size.x * sin(sweepAbs * 0.5);
287
+ }
288
+ default: { return -1.0; }
289
+ }
290
+ }
291
+
292
+ /** Wraps an angle into [0, TAU). */
293
+ fn wrap_tau(angle: f32) -> f32 {
294
+ let wrapped = angle % TAU;
295
+ return select(wrapped + TAU, wrapped, wrapped >= 0.0);
296
+ }
297
+
298
+ /** Distance from the sample to the segment a-b, and how far along that segment it sits. */
299
+ fn nearest_edge(p: vec2f, a: vec2f, b: vec2f) -> vec2f {
300
+ let edge = b - a;
301
+ let along = clamp(dot(p - a, edge) / max(dot(edge, edge), EPSILON), 0.0, 1.0);
302
+ return vec2f(length(p - (a + edge * along)), along * length(edge));
303
+ }
304
+
305
+ /**
306
+ * Arc length of an ellipse from angle 0 to theta, Simpson-integrated in 12 steps. The ellipse
307
+ * has no elementary arc-length formula, so this is the one shape whose dash spacing is an
308
+ * approximation rather than a closed form.
309
+ */
310
+ fn ellipse_arc(theta: f32, radii: vec2f) -> f32 {
311
+ let steps = 12u;
312
+ let h = theta / f32(steps);
313
+ var sum = 0.0;
314
+ for (var i = 0u; i <= steps; i += 1u) {
315
+ let a = h * f32(i);
316
+ let speed = sqrt(radii.x * radii.x * sin(a) * sin(a) + radii.y * radii.y * cos(a) * cos(a));
317
+ var weight = 2.0;
318
+ if (i == 0u || i == steps) { weight = 1.0; }
319
+ else if (i % 2u == 1u) { weight = 4.0; }
320
+ sum = sum + weight * speed;
321
+ }
322
+ return sum * h / 3.0;
323
+ }
324
+
325
+ /** Rounded rectangle: four straight edges and four quarter arcs, from the top-right corner. */
326
+ fn rounded_rect_perimeter(p: vec2f, halfSize: vec2f, cornerRadius: f32) -> f32 {
327
+ let corner = clamp(cornerRadius, 0.0, min(halfSize.x, halfSize.y));
328
+ let inner = max(halfSize - vec2f(corner), vec2f(0.0));
329
+ let center = clamp(p, -inner, inner);
330
+ let delta = p - center;
331
+ let straightX = 2.0 * inner.x;
332
+ let straightY = 2.0 * inner.y;
333
+ let quarter = corner * PI * 0.5;
334
+ let leftBase = straightX + quarter;
335
+ let bottomBase = straightX + 2.0 * quarter + straightY;
336
+ // Right edge starts after the bottom-right quarter arc: two straight runs, three quarter
337
+ // arcs and one straight height have already been walked.
338
+ let rightBase = 2.0 * straightX + 3.0 * quarter + straightY;
339
+ let topRightBase = 2.0 * straightX + 2.0 * straightY + 3.0 * quarter;
340
+ if (abs(delta.x) > EPSILON && abs(delta.y) > EPSILON) {
341
+ let phi = wrap_tau(atan2(delta.y, delta.x));
342
+ if (phi < PI * 0.5) { return topRightBase + phi * corner; }
343
+ if (phi < PI) { return straightX + (phi - PI * 0.5) * corner; }
344
+ if (phi < PI * 1.5) { return leftBase + straightY + (phi - PI) * corner; }
345
+ return bottomBase + straightX + (phi - PI * 1.5) * corner;
346
+ }
347
+ if (abs(delta.y) > EPSILON) {
348
+ if (delta.y > 0.0) { return inner.x - p.x; }
349
+ return bottomBase + (p.x + inner.x);
350
+ }
351
+ if (delta.x > 0.0) { return rightBase + (p.y + inner.y); }
352
+ return leftBase + (inner.y - p.y);
353
+ }
354
+
355
+ /** Capsule: upper cap, left edge, lower cap, right edge, from the upper-right cap start. */
356
+ fn capsule_perimeter(p: vec2f, radius: f32, halfLength: f32) -> f32 {
357
+ let arc = PI * radius;
358
+ if (p.y >= halfLength) {
359
+ return clamp(atan2(p.y - halfLength, p.x), 0.0, PI) * radius;
360
+ }
361
+ if (p.y <= -halfLength) {
362
+ let angle = clamp(wrap_tau(atan2(p.y + halfLength, p.x)), PI, TAU);
363
+ return arc + 2.0 * halfLength + (angle - PI) * radius;
364
+ }
365
+ if (p.x >= 0.0) { return 2.0 * arc + 2.0 * halfLength + (p.y + halfLength); }
366
+ return arc + (halfLength - p.y);
367
+ }
368
+
369
+ /** Annulus: outer ring forwards, then inner ring backwards, so the band stays continuous. */
370
+ fn annulus_perimeter(p: vec2f, outer: f32, inner: f32) -> f32 {
371
+ let radius = length(p);
372
+ let angle = wrap_tau(atan2(p.y, p.x));
373
+ if (abs(radius - outer) <= abs(radius - inner)) { return angle * outer; }
374
+ return TAU * outer + (TAU - angle) * inner;
375
+ }
376
+
377
+ /** Arc: only the swept wedge has a perimeter, so samples past an end clamp to that end. */
378
+ fn arc_perimeter(p: vec2f, radius: f32, start: f32, sweep: f32) -> f32 {
379
+ let sweepAbs = abs(sweep);
380
+ let angle = wrap_tau(atan2(p.y, p.x));
381
+ var offset = wrap_tau(angle - start);
382
+ if (sweep < 0.0) { offset = wrap_tau(start - angle); }
383
+ return min(offset, sweepAbs) * radius;
384
+ }
385
+
386
+ /** Sector: arc, then the radius at the arc end, then the radius back to the arc start. */
387
+ fn sector_perimeter(p: vec2f, radius: f32, start: f32, sweep: f32) -> f32 {
388
+ let sweepAbs = abs(sweep);
389
+ let angle = wrap_tau(atan2(p.y, p.x));
390
+ var offset = wrap_tau(angle - start);
391
+ if (sweep < 0.0) { offset = wrap_tau(start - angle); }
392
+ let startDir = vec2f(cos(start), sin(start)) * radius;
393
+ let endDir = vec2f(cos(start + sweep), sin(start + sweep)) * radius;
394
+ let startEdge = nearest_edge(p, vec2f(0.0), startDir);
395
+ let endEdge = nearest_edge(p, vec2f(0.0), endDir);
396
+ let arcLength = sweepAbs * radius;
397
+ let arcDistance = select(abs(length(p) - radius), min(length(p - startDir), length(p - endDir)), offset > sweepAbs);
398
+ if (arcDistance <= startEdge.x && arcDistance <= endEdge.x) {
399
+ return min(offset, sweepAbs) * radius;
400
+ }
401
+ if (endEdge.x <= startEdge.x) { return arcLength + (radius - endEdge.y); }
402
+ return arcLength + radius + startEdge.y;
403
+ }
404
+
405
+ /** Segment: the arc, then the chord from the arc end back to its start. */
406
+ fn segment_perimeter(p: vec2f, radius: f32, start: f32, sweep: f32) -> f32 {
407
+ let sweepAbs = abs(sweep);
408
+ let angle = wrap_tau(atan2(p.y, p.x));
409
+ var offset = wrap_tau(angle - start);
410
+ if (sweep < 0.0) { offset = wrap_tau(start - angle); }
411
+ let arcStart = vec2f(cos(start), sin(start)) * radius;
412
+ let arcEnd = vec2f(cos(start + sweep), sin(start + sweep)) * radius;
413
+ let chord = nearest_edge(p, arcEnd, arcStart);
414
+ let arcLength = sweepAbs * radius;
415
+ let arcDistance = select(abs(length(p) - radius), min(length(p - arcStart), length(p - arcEnd)), offset > sweepAbs);
416
+ if (arcDistance <= chord.x) { return min(offset, sweepAbs) * radius; }
417
+ return arcLength + chord.y;
418
+ }
419
+
420
+ /** Regular polygon: pick the angular sector, then the projection onto that edge. */
421
+ fn polygon_perimeter(p: vec2f, radius: f32, sides: u32) -> f32 {
422
+ let count = max(sides, 3u);
423
+ let step = TAU / f32(count);
424
+ let edgeLength = 2.0 * radius * sin(PI / f32(count));
425
+ let angle = wrap_tau(atan2(p.y, p.x));
426
+ let index = min(u32(floor(angle / step)), count - 1u);
427
+ let start = vec2f(cos(step * f32(index)), sin(step * f32(index))) * radius;
428
+ let end = vec2f(cos(step * f32(index + 1u)), sin(step * f32(index + 1u))) * radius;
429
+ return f32(index) * edgeLength + nearest_edge(p, start, end).y;
430
+ }
431
+
432
+ /** Triangle: three edges, from the first vertex. */
433
+ fn triangle_perimeter(p: vec2f, a: vec2f, b: vec2f, c: vec2f) -> f32 {
434
+ let ab = nearest_edge(p, a, b);
435
+ let bc = nearest_edge(p, b, c);
436
+ let ca = nearest_edge(p, c, a);
437
+ let lab = length(b - a);
438
+ let lbc = length(c - b);
439
+ if (ab.x <= bc.x && ab.x <= ca.x) { return ab.y; }
440
+ if (bc.x <= ca.x) { return lab + bc.y; }
441
+ return lab + lbc + ca.y;
442
+ }
443
+
444
+ /** Rhombus: four edges through the four diagonal tips, from the +x tip. */
445
+ fn rhombus_perimeter(p: vec2f, halfWidth: f32, halfHeight: f32) -> f32 {
446
+ let v0 = vec2f(halfWidth, 0.0);
447
+ let v1 = vec2f(0.0, halfHeight);
448
+ let v2 = vec2f(-halfWidth, 0.0);
449
+ let v3 = vec2f(0.0, -halfHeight);
450
+ let e0 = nearest_edge(p, v0, v1);
451
+ let e1 = nearest_edge(p, v1, v2);
452
+ let e2 = nearest_edge(p, v2, v3);
453
+ let e3 = nearest_edge(p, v3, v0);
454
+ let l0 = length(v1 - v0);
455
+ let l1 = length(v2 - v1);
456
+ let l2 = length(v3 - v2);
457
+ if (e0.x <= e1.x && e0.x <= e2.x && e0.x <= e3.x) { return e0.y; }
458
+ if (e1.x <= e2.x && e1.x <= e3.x) { return l0 + e1.y; }
459
+ if (e2.x <= e3.x) { return l0 + l1 + e2.y; }
460
+ return l0 + l1 + l2 + e3.y;
461
+ }
462
+
463
+ /**
464
+ * Arc length along the outline at the nearest outline point, in shape-space units. Every
465
+ * primitive now has a closed form except the ellipse, which is integrated numerically.
466
+ */
467
+ fn perimeter_length(shape: u32, p: vec2f, size: vec2f, vertexB: vec2f, vertexC: vec2f, params: vec4f, sides: u32) -> f32 {
468
+ switch shape {
469
+ case SHAPE_RECT: { return rect_perimeter(p, size); }
470
+ case SHAPE_ROUNDED_RECT: { return rounded_rect_perimeter(p, size, params.x); }
471
+ case SHAPE_CIRCLE: { return wrap_tau(atan2(p.y, p.x)) * size.x; }
472
+ case SHAPE_ELLIPSE: {
473
+ let theta = wrap_tau(atan2(p.y / max(size.y, EPSILON), p.x / max(size.x, EPSILON)));
474
+ return ellipse_arc(theta, size);
475
+ }
476
+ case SHAPE_ARC: { return arc_perimeter(p, size.x, params.z, params.w); }
477
+ case SHAPE_ANNULUS: { return annulus_perimeter(p, size.x, size.y); }
478
+ case SHAPE_CAPSULE: { return capsule_perimeter(p, size.x, size.y); }
479
+ case SHAPE_POLYGON: { return polygon_perimeter(p, size.x, sides); }
480
+ case SHAPE_TRIANGLE: { return triangle_perimeter(p, size, vertexB, vertexC); }
481
+ case SHAPE_RHOMBUS: { return rhombus_perimeter(p, size.x, size.y); }
482
+ case SHAPE_SECTOR: { return sector_perimeter(p, size.x, params.z, params.w); }
483
+ case SHAPE_SEGMENT: { return segment_perimeter(p, size.x, params.z, params.w); }
484
+ default: { return -1.0; }
485
+ }
486
+ }
487
+
230
488
  fn clip_to_framebuffer(clip: vec4f) -> vec2f {
231
489
  let ndc = clip.xy / max(clip.w, EPSILON);
232
490
  return vec2f((ndc.x * 0.5 + 0.5) * camera.viewportSize.x, (0.5 - ndc.y * 0.5) * camera.viewportSize.y);
@@ -264,6 +522,7 @@ fn clip_to_framebuffer(clip: vec4f) -> vec2f {
264
522
  out.rotation = rotation;
265
523
  out.sides = (input.flags >> 8u) & 0xffu;
266
524
  out.flags = input.flags;
525
+ out.dash = input.dash;
267
526
  return out;
268
527
  }
269
528
 
@@ -280,7 +539,35 @@ fn clip_to_framebuffer(clip: vec4f) -> vec2f {
280
539
  // Relative to the outer edge of the stroke, so one coverage ramp serves fill and stroke alike.
281
540
  let signedPixel = select(abs(distancePixel) - max(input.params.y, 0.0) * 0.5, distancePixel, filled);
282
541
  let width = max(fwidth(signedPixel), EPSILON);
283
- let coverage = clamp(0.5 - signedPixel / width, 0.0, 1.0);
542
+ var coverage = clamp(0.5 - signedPixel / width, 0.0, 1.0);
543
+ if (coverage <= 0.0) { discard; }
544
+
545
+ // Dashed outlines: walk the perimeter parameter and cut the stroke in the gaps. The transition
546
+ // is spread over one canvas pixel so a dash edge is as soft as the shape's own edge.
547
+ if ((input.flags & 1u) == 0u && input.dash.y > 0.0) {
548
+ let path = perimeter_length(input.shape, local, input.size, input.vertexB, input.vertexC, input.params, input.sides);
549
+ if (path >= 0.0) {
550
+ let requested = input.dash.x + input.dash.y;
551
+ let total = perimeter_total(input.shape, input.size, input.vertexB, input.vertexC, input.params, input.sides);
552
+ var period = requested;
553
+ var visible = input.dash.x;
554
+ if (total > 0.0) {
555
+ // Snap the period to a whole number of turns, keeping the requested duty cycle: the
556
+ // closed outline then ends exactly where its dash pattern started.
557
+ let count = max(round(total / requested), 1.0);
558
+ period = total / count;
559
+ visible = period * (input.dash.x / requested);
560
+ }
561
+ let along = fract((path + input.dash.z) / period) * period;
562
+ let pixel = 1.0 / scale;
563
+ // Distance to the nearest pattern boundary: the dash start, the dash end, and the next
564
+ // dash start that fract() wraps around to.
565
+ let edge = min(min(along, abs(along - visible)), period - along);
566
+ var dashCoverage = smoothstep(0.0, pixel, edge);
567
+ if (along > visible) { dashCoverage = 1.0 - dashCoverage; }
568
+ coverage = coverage * dashCoverage;
569
+ }
570
+ }
284
571
  if (coverage <= 0.0) { discard; }
285
572
  return vec4f(input.color.rgb, input.color.a * coverage);
286
573
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vgpu-render",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "WebGPU debug-draw, gizmo, infinite grid and text layer built on vgpu",
5
5
  "license": "MIT",
6
6
  "type": "module",