Skip to content

Commit 29ebd11

Browse files
authored
feat: updates to projected lines (WebGL) (#666)
### Summary This simplifies the existing `ProjectedLine` renderable and fixes a few longstanding issues with it. It now supports properly mitered corners, closed paths, and width defined in screen pixels. All of this leas to more uniform appearance and consistent, predictable behavior. This also fixes issues with how lines appeared in viewports, fixing the appearance of the axes helper in a 4-up view, for example. While here, I removed support for "tapering" along the path. This was previously used to convey a sense of temporal movement for cell tracking, but I was never happy with it. We can bring that back in better shape if/when required. Note this only touches the WebGL rendering of these lines. I plan to also port this to the WebGPU renderer to learn more about WebGPU and the current implementation of that renderer. ### Related Issue Closes N/A ### Tests & Checks #### on `main` <img width="1389" height="1202" alt="Screenshot 2026-05-14 at 1 00 54 PM" src="https://github.com/user-attachments/assets/72ad8372-0308-4236-b519-64d057e3d587" /> #### with this PR <img width="1285" height="1075" alt="Screenshot 2026-05-14 at 11 23 02 AM" src="https://github.com/user-attachments/assets/7ed92592-6af6-4c52-b623-dee9a9a489b7" />
1 parent 6fc60b5 commit 29ebd11

7 files changed

Lines changed: 51 additions & 83 deletions

File tree

src/core/geometry.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ type GeometryAttributeType =
1111
| "next_position"
1212
| "previous_position"
1313
| "direction"
14-
| "path_proportion"
1514
| "color"
1615
| "size"
1716
| "marker";
@@ -23,10 +22,9 @@ export const GeometryAttributeIndex: Record<GeometryAttributeType, number> = {
2322
next_position: 3,
2423
previous_position: 4,
2524
direction: 5,
26-
path_proportion: 6,
27-
color: 7,
28-
size: 8,
29-
marker: 9,
25+
color: 6,
26+
size: 7,
27+
marker: 8,
3028
};
3129

3230
type GeometryAttribute = {

src/objects/geometry/projected_line_geometry.ts

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -34,43 +34,41 @@ export class ProjectedLineGeometry extends Geometry {
3434
itemSize: 1,
3535
offset: 9 * Float32Array.BYTES_PER_ELEMENT,
3636
});
37-
this.addAttribute({
38-
type: "path_proportion",
39-
itemSize: 1,
40-
offset: 10 * Float32Array.BYTES_PER_ELEMENT,
41-
});
4237
}
4338

4439
private createVertices(path: vec3[]): Float32Array {
45-
const vertices = new Float32Array(2 * path.length * (3 + 3 + 3 + 1 + 1));
40+
const vertices = new Float32Array(2 * path.length * (3 + 3 + 3 + 1));
41+
42+
// If the path's first and last points coincide, treat it as a closed
43+
// loop: the first vertex's `previous` wraps to path[n-2] and the last
44+
// vertex's `next` wraps to path[1]. Both endpoints then take the
45+
// middle-vertex branch in the shader and produce a proper miter at the
46+
// closing seam.
47+
const closed =
48+
path.length >= 3 && vec3.equals(path[0], path[path.length - 1]);
4649

4750
let c = 0;
48-
let path_proportion = 0.0;
49-
const total_distance = path.reduce((acc, curr, i) => {
50-
return acc + vec3.distance(curr, path[i + 1] ?? curr);
51-
}, 0.0);
5251
for (const i of [...Array(path.length).keys()]) {
5352
for (const direction of [-1.0, 1.0]) {
5453
const current = path[i];
5554
vertices[c++] = current[0];
5655
vertices[c++] = current[1];
5756
vertices[c++] = current[2];
5857

59-
const previous = path[i - 1] ?? path[i];
58+
const previous =
59+
i === 0 ? (closed ? path[path.length - 2] : path[i]) : path[i - 1];
6060
vertices[c++] = previous[0];
6161
vertices[c++] = previous[1];
6262
vertices[c++] = previous[2];
6363

64-
const next = path[i + 1] ?? path[i];
64+
const next =
65+
i === path.length - 1 ? (closed ? path[1] : path[i]) : path[i + 1];
6566
vertices[c++] = next[0];
6667
vertices[c++] = next[1];
6768
vertices[c++] = next[2];
6869

6970
vertices[c++] = direction;
70-
vertices[c++] = path_proportion;
7171
}
72-
path_proportion +=
73-
vec3.distance(path[i], path[i + 1] ?? path[i]) / total_distance;
7472
}
7573

7674
return vertices;

src/objects/renderable/projected_line.ts

Lines changed: 1 addition & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -6,29 +6,17 @@ type LineParameters = {
66
geometry: ProjectedLineGeometry;
77
color: ColorLike;
88
width: number;
9-
taperOffset?: number;
10-
taperPower?: number;
119
};
1210

1311
export class ProjectedLine extends RenderableObject {
1412
private color_: Color;
1513
private width_: number;
16-
private taperOffset_: number = 0.5;
17-
private taperPower_: number = 0.0;
1814

19-
constructor({
20-
geometry,
21-
color,
22-
width,
23-
taperOffset,
24-
taperPower,
25-
}: LineParameters) {
15+
constructor({ geometry, color, width }: LineParameters) {
2616
super();
2717
this.geometry = geometry;
2818
this.color_ = Color.from(color);
2919
this.width_ = width;
30-
this.taperOffset_ = taperOffset ?? this.taperOffset_;
31-
this.taperPower_ = taperPower ?? this.taperPower_;
3220
this.programName = "projectedLine";
3321
}
3422

@@ -52,28 +40,10 @@ export class ProjectedLine extends RenderableObject {
5240
this.width_ = value;
5341
}
5442

55-
public get taperOffset() {
56-
return this.taperOffset_;
57-
}
58-
59-
public set taperOffset(value: number) {
60-
this.taperOffset_ = value;
61-
}
62-
63-
public get taperPower() {
64-
return this.taperPower_;
65-
}
66-
67-
public set taperPower(value: number) {
68-
this.taperPower_ = value;
69-
}
70-
7143
public override getUniforms() {
7244
return {
7345
LineColor: this.color.rgb,
7446
LineWidth: this.width,
75-
TaperOffset: this.taperOffset,
76-
TaperPower: this.taperPower,
7747
};
7848
}
7949
}

src/renderers/shaders/points_vert.glsl

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
precision mediump float;
44

55
layout (location = 0) in vec3 inPosition;
6-
layout (location = 7) in vec4 inColor;
7-
layout (location = 8) in float inSize;
8-
layout (location = 9) in float inMarker;
6+
layout (location = 6) in vec4 inColor;
7+
layout (location = 7) in float inSize;
8+
layout (location = 8) in float inMarker;
99

1010
uniform mat4 Projection;
1111
uniform mat4 ModelView;

src/renderers/shaders/projected_line_frag.glsl

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ precision mediump float;
55
layout (location = 0) out vec4 fragColor;
66

77
uniform vec3 LineColor;
8+
uniform float u_opacity;
89

910
void main() {
10-
fragColor = vec4(LineColor, 1.0);
11+
fragColor = vec4(LineColor, u_opacity);
1112
}

src/renderers/shaders/projected_line_vert.glsl

Lines changed: 21 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,14 @@
11
#version 300 es
22

3-
const float PI = 3.14159265;
4-
53
layout (location = 0) in vec3 inPosition;
64
layout (location = 3) in vec3 inPrevPosition;
75
layout (location = 4) in vec3 inNextPosition;
86
layout (location = 5) in float direction;
9-
layout (location = 6) in float path_proportion;
107

118
uniform mat4 Projection;
129
uniform mat4 ModelView;
1310
uniform vec2 Resolution;
1411
uniform float LineWidth;
15-
uniform float TaperOffset;
16-
uniform float TaperPower;
1712

1813
// adapted from https://github.com/mattdesl/webgl-lines
1914
void main() {
@@ -28,36 +23,35 @@ void main() {
2823
vec2 currScreen = (currPos.xy / currPos.w) * aspectVec;
2924
vec2 nextScreen = (nextPos.xy / nextPos.w) * aspectVec;
3025

31-
vec2 diff;
26+
// direction is + or -; which way to project the vertex away from the path
27+
float d = sign(direction);
28+
29+
// `normal` points perpendicular to the path in screen space
30+
vec2 normal;
31+
// `miterLength` extends the offset along the normal (with a limit)
32+
// this make a nicer corner and keeps the line thickness more uniform;
33+
float miterLength = 1.0;
3234
if (prevPos == currPos) {
3335
// first point on the path
34-
diff = nextScreen - currScreen;
36+
vec2 dir = normalize(nextScreen - currScreen);
37+
normal = vec2(-dir.y, dir.x);
3538
} else if (nextPos == currPos) {
3639
// last point on the path
37-
diff = currScreen - prevScreen;
40+
vec2 dir = normalize(currScreen - prevScreen);
41+
normal = vec2(-dir.y, dir.x);
3842
} else {
39-
// middle point on the path
40-
// combine the two directions to get a cheap miter
41-
// this is not a true miter join, but it also doesn't explode
42-
vec2 prevDiff = currScreen - prevScreen;
43-
vec2 nextDiff = nextScreen - currScreen;
44-
diff = normalize(prevDiff) + normalize(nextDiff);
45-
}
46-
47-
// direction is + or -; which way to project the vertex away from the path
48-
// path_proportion is the distance along the path, from 0 to 1
49-
float d = sign(direction);
50-
float taper = 1.0;
51-
if (TaperPower > 0.0) {
52-
// glsl `pow(x, y)` is undefined if x < 0 or x = 0 and y <= 0
53-
float t = clamp(path_proportion - TaperOffset, -0.5, 0.5);
54-
float angle = PI * t;
55-
taper = pow(cos(angle), TaperPower);
43+
// middle point on the path: add miter along the bisector
44+
vec2 prevDir = normalize(currScreen - prevScreen);
45+
vec2 nextDir = normalize(nextScreen - currScreen);
46+
vec2 tangent = normalize(prevDir + nextDir);
47+
normal = vec2(-tangent.y, tangent.x);
48+
vec2 perpPrev = vec2(-prevDir.y, prevDir.x);
49+
miterLength = 1.0 / max(dot(normal, perpPrev), 0.1);
5650
}
57-
vec2 normal = normalize(vec2(-diff.y, diff.x));
5851

52+
// `normal * LineWidth / Resolution` means LineWidth is in pixels
5953
vec4 offset = vec4(
60-
normal * d * taper * LineWidth / 2.0 / aspectVec,
54+
(normal * LineWidth / Resolution) * miterLength * d,
6155
0.0,
6256
0.0
6357
);

src/renderers/webgl_renderer.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export class WebGLRenderer extends Renderer {
3434
private readonly textures_: WebGLTextures;
3535
private readonly state_: WebGLState;
3636
private renderedObjectsPerFrame_ = 0;
37+
private currentViewportSize_: [number, number] = [0, 0];
3738

3839
constructor(canvas: HTMLCanvasElement) {
3940
super(canvas);
@@ -96,6 +97,9 @@ export class WebGLRenderer extends Renderer {
9697
this.state_.setViewport(viewportBox);
9798
this.clear();
9899

100+
const viewportRect = viewportBox.toRect();
101+
this.currentViewportSize_ = [viewportRect.width, viewportRect.height];
102+
99103
const frustum = viewport.camera.frustum;
100104

101105
this.state_.setDepthMask(true);
@@ -192,7 +196,10 @@ export class WebGLRenderer extends Renderer {
192196
axisDirection,
193197
camera.projectionMatrix
194198
);
195-
const resolution = [this.canvas.width, this.canvas.height];
199+
200+
// per-viewport size in pixels — shaders that compute screen-space offsets
201+
// need the actual rendered region, not the full-canvas dimensions
202+
const resolution = this.currentViewportSize_;
196203

197204
const objectUniforms = object.getUniforms();
198205
const layerUniforms = layer.getUniforms();

0 commit comments

Comments
 (0)