I'm trying to use @react-three/fiber
and @react-three/drei
to render a fat, multi-colored line onto a canvas. Here is the result I'm trying to achieve:
It's a fat, multi-colored line. In this image it represents the first quarter of a sine wave, though the shape of the line's curve is unimportant. By default the line is colored red. Once it crosses an arbitrary threshold on the Y-axis (in this image, 0.5
), it changes to green. The actual thickness of the line is also arbitrary and not critical, just as long as it's thicker than the default thickness of one pixel.
I'm currently able to achieve this result by rendering a SpecialCustomFatLine
component, which is a <mesh>
with <bufferGeometry>
and a <shaderMaterial>
(for a full, working minimal reproducible example, please view this codesandbox link):
function* pairwise(points: Vector2[]) {
const size = 2;
for (let index = 0; index + size <= points.length; index++) {
yield points.slice(index, index + size);
}
}
function toLineSegment(lineWidth: number, ...vectors: Vector2[]): Vector2[] {
const a = vectors[0];
const b = vectors[1];
const w = lineWidth;
return [
new Vector2(a.x, a.y + w),
new Vector2(b.x, b.y + w),
a,
a,
new Vector2(b.x, b.y + w),
b,
a,
b,
new Vector2(a.x, a.y - w),
new Vector2(a.x, a.y - w),
b,
new Vector2(b.x, b.y - w)
];
}
const SpecialCustomFatLine = (props: SpecialLineProps) => {
const { points, lineWidth, threshold } = props;
const onGeometryUpdate = useCallback(
(geometry: BufferGeometry) => {
// BufferGeometry needs at least three vertices because it renders triangles.
// Generate a final list of points from 'points' to make the line mesh appear fatter.
const generatedPoints = Array.from(pairwise(points)).flatMap((chunk) =>
toLineSegment(lineWidth / 100.0, ...chunk)
);
geometry.setFromPoints(generatedPoints);
},
[points]
);
const onMaterialUpdate = useCallback(
(material: ShaderMaterial) => {
material.uniforms.threshold.value = threshold;
},
[threshold]
);
const uniforms = useMemo(
() => ({
threshold: {
value: threshold
}
}),
[]
);
const vertexShader = useMemo(
() => `
out vec4 worldPosition;
void main()
{
worldPosition = modelMatrix * vec4(position, 1.0);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
[]
);
const fragmentShader = useMemo(
() => `
uniform float threshold; // Y-axis coordinate above which the fragment turns green.
in vec4 worldPosition; // Fragment world position?
void main()
{
if (worldPosition.y >= threshold)
{
gl_FragColor.rgba = vec4(0.0, 1.0, 0.0, 1.0); // Green
}
else
{
gl_FragColor.rgba = vec4(1.0, 0.0, 0.0, 1.0); // Red
}
}
`,
[]
);
return (
<mesh>
<bufferGeometry onUpdate={onGeometryUpdate} />
<shaderMaterial
uniforms={uniforms}
vertexShader={vertexShader}
fragmentShader={fragmentShader}
onUpdate={onMaterialUpdate}
side={DoubleSide}
/>
</mesh>
);
};
As you can see, the mesh I'm generating for my line simply adds triangles above- and below the points that make up the original line to make it appear "fatter", at least on the Y-axis. I'm using a fragment shader to color all fragments above a certain world position green. To be honest, I do not understand how this works fully, as I found this solution online. The world position for each fragment is passed in to the fragment shader from the vertex shader, as the world position can only be calculated there (again, not sure if that's correct. I am totally unfamiliar with the various matrices that are available in the vertex shader, and I have no idea how to use them. Also, it seems weird to me that the vertex shader should know how to calculate a world position per-fragment, since the vertex shader runs once per vertex, right? And there are way more fragments than there are vertices... but this seems to work, so I'm not touching it.)
I'm not happy with this solution, however. I shouldn't have to re-invent drawing fat lines. I'd much rather use a well-vetted third-party component like <Line>
from @react-three/drei
(which uses meshline under the hood I believe). Since I'm already using @react-three/drei
, I started working on a different implementation.
However, I'm running into an issue. It seems that drei
's fat lines are implemented via a vertex shader, which means if I replace that vertex shader with my own (which I need to calculate fragment world positions), I break everything and the fat line won't be rendered at all.
The only alternative I've been able to come up with so far is to perform string-manipulation and replace parts of the existing fat line vertex shader with my fragment world position calculations, which is extremely hacky. That code looks like this (minimal reproducible codesandbox link):
const SpecialFatLine = (props: SpecialLineProps) => {
const { points, threshold } = props;
const onUpdate = useCallback((self: Line2 | LineMaterial) => {
if (self instanceof ShaderMaterial) {
self.vertexShader = self.vertexShader.replace(
"#ifdef WORLD_UNITS",
"out vec4 worldPosition;\n\t\t#ifdef WORLD_UNITS"
);
self.vertexShader = self.vertexShader.replace(
"void main() {",
"void main() {\n\t\t\tworldPosition = modelMatrix * vec4(position, 1.0);"
);
console.log(self.vertexShader);
}
}, []);
const fragmentShader = useMemo(
() => `
uniform float threshold; // Y-axis coordinate above which the fragment turns green.
in vec4 worldPosition; // Fragment world position?
void main()
{
if (worldPosition.y >= threshold)
{
gl_FragColor.rgba = vec4(0.0, 1.0, 0.0, 1.0); // Green
}
else
{
gl_FragColor.rgba = vec4(1.0, 0.0, 0.0, 1.0); // Red
}
}
`,
[]
);
return (
<Line
points={points}
lineWidth={10}
// Can't actually replace the vertexShader...
// vertexShader={...}
fragmentShader={fragmentShader}
onUpdate={onUpdate}
/>
);
};
But that results in:
At this point I'm stuck. Surely I can't be the only person on the planet who has ever thought of applying shaders to a drei
/meshline
fat line, right? How can I determine each fragment's world position, so that I may color the fat line in the way I want? I'm not attached to any one particular solution, so if you have alternative ideas, by all means - that being said I have tried ditching shaders altogether and coloring the line's vertices instead. That did not look good.