Skip to content

Commit ad3ef31

Browse files
deluksicDavid Emanuel Luksic
authored andcommitted
@typegpu/geometry Improve line rendering APIs
1 parent f935fbb commit ad3ef31

22 files changed

Lines changed: 273 additions & 152 deletions

File tree

apps/typegpu-docs/src/examples/geometry/lines-combinations/index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@ import {
55
joinSlot,
66
lineSegmentIndices,
77
lineSegmentLeftIndices,
8-
lineSegmentVariableWidth,
8+
polylineVariableWidth,
99
lineSegmentWireframeIndices,
1010
startCapSlot,
11+
arrowCapParamsSlot,
1112
} from '@typegpu/geometry';
1213
import tgpu, { type ColorAttachment } from 'typegpu';
1314
import {
@@ -140,7 +141,7 @@ const mainVertex = tgpu.vertexFn({
140141
return Out();
141142
}
142143

143-
const result = lineSegmentVariableWidth(vertexIndex, A, B, C, D, MAX_JOIN_COUNT);
144+
const result = polylineVariableWidth(A, B, C, D, vertexIndex, MAX_JOIN_COUNT);
144145

145146
return {
146147
outPos: vec4f(result.vertexPosition * result.w, 0, result.w),
@@ -287,6 +288,7 @@ function createPipelines() {
287288
.with(startCapSlot, startCap)
288289
.with(endCapSlot, endCap)
289290
.with(testCaseSlot, testCase)
291+
.with(arrowCapParamsSlot, { length: 7.5, width: 1.75, slant: -0.8 })
290292
.createRenderPipeline({
291293
vertex: mainVertex,
292294
fragment: mainFragment,
@@ -303,6 +305,7 @@ function createPipelines() {
303305
.with(startCapSlot, startCap)
304306
.with(endCapSlot, endCap)
305307
.with(testCaseSlot, testCase)
308+
.with(arrowCapParamsSlot, { length: 7.5, width: 1.75, slant: -0.8 })
306309
.createRenderPipeline({
307310
vertex: mainVertex,
308311
fragment: outlineFragment,

apps/typegpu-docs/src/examples/simulation/wind-map/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {
33
endCapSlot,
44
LineControlPoint,
55
lineSegmentIndices,
6-
lineSegmentVariableWidth,
6+
polylineVariableWidth,
77
startCapSlot,
88
} from '@typegpu/geometry';
99
import tgpu from 'typegpu';
@@ -177,7 +177,7 @@ const mainVertex = tgpu.vertexFn({
177177
radius: lineWidth(f32(trailIndexOriginal + 3) / (TRAIL_LENGTH - 1)),
178178
});
179179

180-
const result = lineSegmentVariableWidth(vertexIndex, A, B, C, D, MAX_JOIN_COUNT);
180+
const result = polylineVariableWidth(A, B, C, D, vertexIndex, MAX_JOIN_COUNT);
181181

182182
return {
183183
outPos: vec4f(result.vertexPosition, 0, 1),

apps/typegpu-docs/tests/individual-example-tests/global-wind-map.test.ts

Lines changed: 30 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -156,23 +156,22 @@ describe('global wind map example', () => {
156156
v: vec2f,
157157
d: vec2f,
158158
fw: vec2f,
159+
cross: vec2f,
159160
start: vec2f,
160161
end: vec2f,
161-
shouldJoin: bool,
162-
isCap: bool,
163162
}
164163
165164
fn rot90ccw(v: vec2f) -> vec2f {
166165
return vec2f(-(v.y), v.x);
167166
}
168167
168+
fn rot90cw(v: vec2f) -> vec2f {
169+
return vec2f(v.y, -(v.x));
170+
}
171+
169172
fn arrow(join: JoinInput, joinVertexIndex: u32, _maxJoinCount: u32) -> vec2f {
170-
var bw = -(normalize(join.fw));
171-
var vert = rot90ccw(bw);
172-
let sgn = sign(cross2d(bw, join.d));
173-
var svert = (vert * sgn);
174-
var v0 = (svert + (bw * 7.5f));
175-
var v1 = (v0 + ((bw + svert) * 1.5f));
173+
var v0 = (join.cross - (join.fw * 7.5f));
174+
var v1 = (v0 + ((join.cross - (join.fw * 0.8f)) * 1.5f));
176175
if ((joinVertexIndex == 0u)) {
177176
return (join.C.position + (v0 * join.C.radius));
178177
}
@@ -183,15 +182,7 @@ describe('global wind map example', () => {
183182
}
184183
185184
fn butt(join: JoinInput, _joinVertexIndex: u32, _maxJoinCount: u32) -> vec2f {
186-
var fw = normalize(join.fw);
187-
var vert = rot90ccw(fw);
188-
let sgn = sign(cross2d(fw, join.d));
189-
var svert = (vert * sgn);
190-
return (join.C.position + (svert * join.C.radius));
191-
}
192-
193-
fn rot90cw(v: vec2f) -> vec2f {
194-
return vec2f(v.y, -(v.x));
185+
return (join.C.position + (join.cross * join.C.radius));
195186
}
196187
197188
fn bisectCcw(a: vec2f, b: vec2f) -> vec2f {
@@ -228,7 +219,7 @@ describe('global wind map example', () => {
228219
return (join.C.position + (dir * join.C.radius));
229220
}
230221
231-
fn lineSegmentVariableWidth(vertexIndex: u32, A: LineControlPoint, B: LineControlPoint, C: LineControlPoint, D: LineControlPoint, maxJoinCount: u32) -> LineSegmentOutput {
222+
fn polylineVariableWidth(A: LineControlPoint, B: LineControlPoint, C: LineControlPoint, D: LineControlPoint, vertexIndex: u32, maxJoinCount: u32) -> LineSegmentOutput {
232223
var AB = (B.position - A.position);
233224
var BC = (C.position - B.position);
234225
var DC = (C.position - D.position);
@@ -271,24 +262,38 @@ describe('global wind map example', () => {
271262
let coreVertexIndex = ((vertexIndex - 2u) & 3u);
272263
let joinVertexIndex = ((vertexIndex - 2u) >> 2u);
273264
var join = JoinInput();
265+
var isCap = false;
266+
var shouldJoin = false;
267+
var normBC = normalize(BC);
268+
var normCB = -(normBC);
269+
var crossL = rot90ccw(normBC);
270+
var crossR = rot90cw(normBC);
274271
if ((coreVertexIndex == 0u)) {
275-
join = JoinInput(B, v2, (*d2), CB, (*d2), select(eAB.nL, (*d3), (joinB.isHairpin || isCapB)), joinB.shouldJoinL, isCapB);
272+
isCap = isCapB;
273+
shouldJoin = joinB.shouldJoinL;
274+
join = JoinInput(B, v2, (*d2), normCB, crossL, (*d2), select(eAB.nL, (*d3), (joinB.isHairpin || isCapB)));
276275
}
277276
else {
278277
if ((coreVertexIndex == 1u)) {
279-
join = JoinInput(B, v3, (*d3), CB, select(eAB.nR, (*d2), (joinB.isHairpin || isCapB)), (*d3), joinB.shouldJoinR, isCapB);
278+
isCap = isCapB;
279+
shouldJoin = joinB.shouldJoinR;
280+
join = JoinInput(B, v3, (*d3), normCB, crossR, select(eAB.nR, (*d2), (joinB.isHairpin || isCapB)), (*d3));
280281
}
281282
else {
282283
if ((coreVertexIndex == 2u)) {
283-
join = JoinInput(C, v4, (*d4), BC, (*d4), select(eDC.nL, (*d5), (joinC.isHairpin || isCapC)), joinC.shouldJoinL, isCapC);
284+
isCap = isCapC;
285+
shouldJoin = joinC.shouldJoinL;
286+
join = JoinInput(C, v4, (*d4), normBC, crossR, (*d4), select(eDC.nL, (*d5), (joinC.isHairpin || isCapC)));
284287
}
285288
else {
286-
join = JoinInput(C, v5, (*d5), BC, select(eDC.nR, (*d4), (joinC.isHairpin || isCapC)), (*d5), joinC.shouldJoinR, isCapC);
289+
isCap = isCapC;
290+
shouldJoin = joinC.shouldJoinR;
291+
join = JoinInput(C, v5, (*d5), normBC, crossL, select(eDC.nR, (*d4), (joinC.isHairpin || isCapC)), (*d5));
287292
}
288293
}
289294
}
290295
var vertexPosition = join.v;
291-
if (join.isCap) {
296+
if (isCap) {
292297
if ((coreVertexIndex < 2u)) {
293298
vertexPosition = arrow(join, joinVertexIndex, maxJoinCount);
294299
}
@@ -297,7 +302,7 @@ describe('global wind map example', () => {
297302
}
298303
}
299304
else {
300-
if (join.shouldJoin) {
305+
if (shouldJoin) {
301306
vertexPosition = round_1(join, joinVertexIndex, maxJoinCount);
302307
}
303308
}
@@ -323,7 +328,7 @@ describe('global wind map example', () => {
323328
var B = LineControlPoint((*particle).positions[iB], lineWidth((f32((trailIndexOriginal + 1u)) / 19f)));
324329
var C = LineControlPoint((*particle).positions[iC], lineWidth((f32((trailIndexOriginal + 2u)) / 19f)));
325330
var D = LineControlPoint((*particle).positions[iD], lineWidth((f32((trailIndexOriginal + 3u)) / 19f)));
326-
var result = lineSegmentVariableWidth(vertexIndex, A, B, C, D, 3u);
331+
var result = polylineVariableWidth(A, B, C, D, vertexIndex, 3u);
327332
return mainVertex_Output(vec4f(result.vertexPosition, 0f, 1f), result.vertexPosition, (f32(trailIndexOriginal) / 19f));
328333
}
329334

apps/typegpu-docs/tests/individual-example-tests/lines-combinations.test.ts

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -129,10 +129,9 @@ describe('lines combinations example', () => {
129129
v: vec2f,
130130
d: vec2f,
131131
fw: vec2f,
132+
cross: vec2f,
132133
start: vec2f,
133134
end: vec2f,
134-
shouldJoin: bool,
135-
isCap: bool,
136135
}
137136
138137
fn rot90ccw(v: vec2f) -> vec2f {
@@ -177,7 +176,7 @@ describe('lines combinations example', () => {
177176
return (join.C.position + (dir * join.C.radius));
178177
}
179178
180-
fn lineSegmentVariableWidth(vertexIndex: u32, A: LineControlPoint, B: LineControlPoint, C: LineControlPoint, D: LineControlPoint, maxJoinCount: u32) -> LineSegmentOutput {
179+
fn polylineVariableWidth(A: LineControlPoint, B: LineControlPoint, C: LineControlPoint, D: LineControlPoint, vertexIndex: u32, maxJoinCount: u32) -> LineSegmentOutput {
181180
var AB = (B.position - A.position);
182181
var BC = (C.position - B.position);
183182
var DC = (C.position - D.position);
@@ -220,24 +219,38 @@ describe('lines combinations example', () => {
220219
let coreVertexIndex = ((vertexIndex - 2u) & 3u);
221220
let joinVertexIndex = ((vertexIndex - 2u) >> 2u);
222221
var join = JoinInput();
222+
var isCap = false;
223+
var shouldJoin = false;
224+
var normBC = normalize(BC);
225+
var normCB = -(normBC);
226+
var crossL = rot90ccw(normBC);
227+
var crossR = rot90cw(normBC);
223228
if ((coreVertexIndex == 0u)) {
224-
join = JoinInput(B, v2, (*d2), CB, (*d2), select(eAB.nL, (*d3), (joinB.isHairpin || isCapB)), joinB.shouldJoinL, isCapB);
229+
isCap = isCapB;
230+
shouldJoin = joinB.shouldJoinL;
231+
join = JoinInput(B, v2, (*d2), normCB, crossL, (*d2), select(eAB.nL, (*d3), (joinB.isHairpin || isCapB)));
225232
}
226233
else {
227234
if ((coreVertexIndex == 1u)) {
228-
join = JoinInput(B, v3, (*d3), CB, select(eAB.nR, (*d2), (joinB.isHairpin || isCapB)), (*d3), joinB.shouldJoinR, isCapB);
235+
isCap = isCapB;
236+
shouldJoin = joinB.shouldJoinR;
237+
join = JoinInput(B, v3, (*d3), normCB, crossR, select(eAB.nR, (*d2), (joinB.isHairpin || isCapB)), (*d3));
229238
}
230239
else {
231240
if ((coreVertexIndex == 2u)) {
232-
join = JoinInput(C, v4, (*d4), BC, (*d4), select(eDC.nL, (*d5), (joinC.isHairpin || isCapC)), joinC.shouldJoinL, isCapC);
241+
isCap = isCapC;
242+
shouldJoin = joinC.shouldJoinL;
243+
join = JoinInput(C, v4, (*d4), normBC, crossR, (*d4), select(eDC.nL, (*d5), (joinC.isHairpin || isCapC)));
233244
}
234245
else {
235-
join = JoinInput(C, v5, (*d5), BC, select(eDC.nR, (*d4), (joinC.isHairpin || isCapC)), (*d5), joinC.shouldJoinR, isCapC);
246+
isCap = isCapC;
247+
shouldJoin = joinC.shouldJoinR;
248+
join = JoinInput(C, v5, (*d5), normBC, crossL, select(eDC.nR, (*d4), (joinC.isHairpin || isCapC)), (*d5));
236249
}
237250
}
238251
}
239252
var vertexPosition = join.v;
240-
if (join.isCap) {
253+
if (isCap) {
241254
if ((coreVertexIndex < 2u)) {
242255
vertexPosition = round_1(join, joinVertexIndex, maxJoinCount);
243256
}
@@ -246,7 +259,7 @@ describe('lines combinations example', () => {
246259
}
247260
}
248261
else {
249-
if (join.shouldJoin) {
262+
if (shouldJoin) {
250263
vertexPosition = round_1(join, joinVertexIndex, maxJoinCount);
251264
}
252265
}
@@ -263,7 +276,7 @@ describe('lines combinations example', () => {
263276
if (((((A.radius < 0f) || (B.radius < 0f)) || (C.radius < 0f)) || (D.radius < 0f))) {
264277
return mainVertex_Output();
265278
}
266-
var result = lineSegmentVariableWidth(vertexIndex, A, B, C, D, 6u);
279+
var result = polylineVariableWidth(A, B, C, D, vertexIndex, 6u);
267280
return mainVertex_Output(vec4f((result.vertexPosition * result.w), 0f, result.w), result.vertexPosition, vec2f(0f, select(0f, 1f, (vertexIndex > 1u))), instanceIndex, vertexIndex, 0u);
268281
}
269282

packages/typegpu-geometry/LinesExplanation.md

Lines changed: 42 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,13 @@ limited. It allows only single pixel width lines! Good for debugging, but
1111
terrible for anything user-facing. With TypeGPU, it is easier than ever to
1212
create reusable, composable libraries. And now, high quality line rendering is
1313
just an `npm install` away! This article serves as documentation for the library
14-
source code. It should also give you an appreciation for how complex line
15-
rendering can actually get.
14+
source code. It should also give you an appreciation for just how complex line
15+
rendering can get!
1616

1717
## Goals
1818

1919
As already described in many online articles, drawing lines using GPU can be
20-
notoriously difficult to do well. There are many different, sometimes
20+
quite involved. There are many different, sometimes
2121
conflicting, goals you might have when it comes to line rendering. Following
2222
goals are considered by this article and implementation:
2323

@@ -34,74 +34,74 @@ goals are considered by this article and implementation:
3434
### Non-goals
3535

3636
- maximum performance
37-
- triangle counts
38-
- minimizing quad-overdraw (having max-area triangles)
37+
- minimizing triangle counts
38+
- minimizing quad-overdraw (producing max-area triangles)
3939

40-
## Single Line Segment
40+
## Single Line
4141

42-
We start with a single segment. Two vertices, `C1` and `C2` (C for center), and
43-
radii `r1` and `r2`.
42+
We start with a single line. Two control points, $A$ and $B$ with
43+
radii $r_A$ and $r_B$.
4444

45-
![-](./assets/basic.svg)
45+
<img src="./assets/basic.svg" style="display: block; margin: 0 auto; width: 100%; max-width: 400px;" />
4646

47-
Two most important directions to compute are `nL` and `nR`, left (CCW) and right
48-
(CW) external tangent **normals**.
47+
Two most important directions to compute are $\hat{n_L}$ and $\hat{n_R}$, left (CCW) and right
48+
(CW) external tangent **normals** (with respect to $\vec{AB}$).
4949

50-
```ts
51-
x = (r1 - r2) / distance(C1, C2);
52-
y = sqrt(1 - x ^ 2);
53-
nL = vec2(x, y);
54-
nR = vec2(x, -y);
50+
```math
51+
x = \frac{r1 - r2}{\|{AB}\|} \\
52+
y = \sqrt{1 - x ^ 2} \\
53+
\hat{n_L} = (x, y) \\
54+
\hat{n_R} = (x, -y)
5555
```
5656

57-
NOTE: in `externalNormals.ts`, additional care is taken to return `nL` and `nR`
58-
rotated relative to the `distance` vector between the circles.
57+
NOTE: in `externalNormals.ts`, additional care is taken to return $\hat{n_L}$ and $\hat{n_R}$
58+
rotated relative to $\vec{AB}$.
5959

6060
Using these two directions, it is trivial to compute all other points necessary
6161
for triangulation:
6262

63-
![-](./assets/triangulation.svg)
63+
<img src="./assets/triangulation.svg" style="display: block; margin: 0 auto; width: 100%; max-width: 400px;" />
6464

65-
**Core** (red) vertices 0-5 are:
65+
**Core** 🔴 vertices 0-5 are:
6666

67-
```ts
68-
v0 = C1;
69-
v1 = C2;
70-
v2 = C1 + r1 * nL;
71-
v3 = C1 + r1 * nR;
72-
v4 = C2 + r2 * nR;
73-
v5 = C2 + r2 * nL;
67+
```math
68+
{\color{red} v_0} = A \\
69+
{\color{red} v_1} = B \\
70+
{\color{red} v_2} = A + r_A n_L \\
71+
{\color{red} v_3} = A + r_A n_R \\
72+
{\color{red} v_4} = B + r_B n_R \\
73+
{\color{red} v_5} = B + r_B n_L
7474
```
7575

76-
Vertices 6+ are called `join` vertices in the code, however they are used for
76+
Vertices 6+ are called `join` vertices in the code, but they are used for
7777
both `joins` and `caps`. Their computation will depend on the type of `join` or
78-
`cap` used. Here, a `round` cap is shown. It is important to note their
78+
`cap` used. Here, an (incomplete) `round` cap is shown. It is important to note their
7979
distribution. Just like the 4 core vertices 2-5, they are distributed CCW
8080
progressively further away from their respective core vertex. This makes it
8181
possible to dynamically vary the number of segments in the joins, while using
8282
the same index buffer. Each join vertex is identified by:
8383

84-
- `coreVertexIndex` which core vertex it belongs to (`v2,v6,v10=0`,
85-
`v3,v7,v11=1` etc.)
86-
- `joinVertexIndex` number of vertices away from the core vertex (`v2=0`,
87-
`v6=1`, `v10=2`)
84+
- `coreVertexIndex` which core vertex it belongs to
85+
- `joinVertexIndex` number of vertices away from the core vertex
8886

8987
```ts
90-
coreVertexIndex = (vertexIndex - 2) % 4;
91-
joinVertexIndex = (vertexIndex - 2) / 4;
88+
const coreVertexIndex = (vertexIndex - 2) % 4;
89+
const joinVertexIndex = (vertexIndex - 2) / 4;
9290
```
9391

94-
NOTE: instead of the slow `% 4` and `/ 4`, real code uses `& 0b11` and `>> 2`.
95-
Probably can be optimized away by wgsl compilers, but you never know.
92+
NOTE: real code uses `& 0b11` and `>> 2` instead of div.
9693

97-
Cap functions can then use `joinVertexIndex` and `maxJoinCount` to compute the
94+
Cap functions can then use `joinVertexIndex / MAX_JOIN_COUNT` to compute the
9895
final position of each join vertex.
9996

10097
If all you want to do is render single line segments, you should use
101-
`singleLineSegmentVariableWidth(A, B)` function. It does exactly what we just
98+
`lineVariableWidth(A, B, vertexIndex, MAX_JOIN_COUNT)` function. It does exactly what we just
10299
discussed and nothing more.
103100

104-
## Joining Line Segments
101+
## Polylines
102+
103+
Easy part is done. Joining segments into polylines is what makes line rendering interesting! First, lets consider how in theory the joining should work. A polyline consists of many joined segments. Each segment's geometry depends on its two neighboring segments. This is why the function accepts 4 consecutive control points $A, B, C, D$ with radii $r_A, r_B, r_C, r_D$.
104+
105+
<img src="./assets/polyline-basic.svg" style="display: block; margin: 0 auto; width: 100%; max-width: 400px;" />
105106

106-
Easy part is done. Joining segments is where the difficulties and edge cases
107-
start. First, lets consider how in theory the joining should work:
107+
The segment that actually gets drawn is $BC$, shaded in $\color{blue} blue$. The function needs $A$ and $D$ in order to compute the join geometry, but it does not create the red shaded regions. Darker shaded regions highlight the join area. Notice that only half the join is handled by each segment.

0 commit comments

Comments
 (0)