How to Build an Interactive Role Relationship Diagram with PIXIJS and Shaders
This tutorial walks through redesigning a complex role‑relationship page using PIXIJS for 2D rendering, custom GLSL shaders for cloud effects, TWEENJS for smooth animations, and careful performance tuning to ensure responsive behavior across devices.
Introduction
We need to revamp an old role‑relationship diagram page, improving the visual presentation of characters and their connections based on a designer's mockup.
Overall Analysis
The page consists of three logical layers: background, relationship lines, and role information. Role information is rendered with plain DOM elements, while the background and relationship layers use a 2D rendering library. Although a 3D engine like THREEJS could render the cloud background, the overall page is better suited to 2D, so PIXIJS is chosen for its speed and rich API. TWEENJS is used for animation.
Implementation Details
Creating the Stage
<code>const app = new PIXI.Application({
width: windowWidth,
height: windowHeight,
antialias: true,
resolution: devicePixelRatio,
transparent: true,
});
app.renderer.autoDensity = true;
app.renderer.resize(windowWidth, windowHeight);
document.getElementById('container').appendChild(app.view);
app.stage.sortableChildren = true;</code>Key PIXI concepts:
antialias : smooths edges of fonts and graphics.
resolution : defines the actual canvas pixel size.
autoDensity : makes CSS size match the device pixel ratio.
sortableChildren : enables zIndex sorting of children.
Background Layer
The background has three sub‑layers: clouds, starry sky, and ground.
Clouds are generated with a custom shader filter that simulates noise‑based textures.
<code>const background = new PIXI.Sprite();
background.width = this.app.screen.width;
background.height = this.app.screen.height;
this.app.stage.addChild(background);
this.filter = new PIXI.Filter(null, fogFragment, {
uResolution: {
x: this.app.screen.width * devicePixelRatio,
y: this.app.screen.height * devicePixelRatio,
},
uTime: 0,
});
background.filters = [this.filter];</code>Each frame the time uniform is increased to animate the fog:
<code>update() {
this.filter.uniforms.uTime += 0.01;
}</code>Fragment shader (simplified):
<code>void main() {
const vec3 c1 = vec3(0.110, 0.110, 0.137);
const vec3 c2 = vec3(0.133, 0.149, 0.247);
vec2 p = gl_FragCoord.xy * 8.0 / uResolution.xx;
float q = fbm(p - uTime * 0.1);
vec2 r = vec2(fbm(p + q + uTime * 0.4 - p.x - p.y), fbm(p + q - uTime * 0.7));
vec3 c = mix(c1, c2, fbm(p + r));
float grad = gl_FragCoord.y / uResolution.y;
gl_FragColor = vec4(c * cos(1.4 * gl_FragCoord.y / uResolution.y), 1.0);
gl_FragColor.xyz *= 0.8 + grad;
}</code>Starry Sky Layer
Stars are created as sprites, positioned radially, and projected from 3D to 2D based on a moving camera.
<code>// Loop to create star sprites
for (let i = 0; i < this.starAmount; i++) {
const star = {
sprite: new PIXI.Sprite(starTexture),
z: 0,
x: 0,
y: 0,
};
star.sprite.anchor.set(0.5);
this.randomizeStar(star, true);
this.app.stage.addChild(star.sprite);
this.stars.push(star);
}
randomizeStar(star, initial) {
star.z = initial ? Math.random() * 2000 : this.cameraZ + Math.random() * 1000 + 2000;
const deg = Math.random() * Math.PI * 2;
const distance = Math.random() * 60 + 1;
star.x = Math.cos(deg) * distance;
star.y = Math.sin(deg) * distance;
}
update(delta) {
this.cameraZ += delta * 10 * this.baseSpeed;
for (let i = 0; i < this.starAmount; i++) {
const star = this.stars[i];
if (star.z < this.cameraZ) {
this.randomizeStar(star);
}
const z = star.z - this.cameraZ;
star.sprite.x = star.x * (this.fov / z) * this.app.renderer.screen.width + this.app.renderer.screen.width / 2;
star.sprite.y = star.y * (this.fov / z) * this.app.renderer.screen.width + this.app.renderer.screen.height / 2;
const distanceScale = Math.max(0, (2000 - z) / 2000) * this.starBaseSize;
star.sprite.scale.set(distanceScale);
}
}</code>Roles and Relationships
Roles are drawn on a Canvas, turned into a PIXI texture, and placed in concentric circles: the main character at the centre, inner and middle circles with six roles each, and an outer circle with up to 24 roles (only 12 visible at a time).
<code>const canvas = document.createElement('canvas');
const texture = PIXI.Texture.from(canvas);
const roleSprite = new PIXI.Sprite(texture);
</code>Positioning uses trigonometry. The first outer role position is calculated with Math.atan , then subsequent roles are spaced evenly:
<code>const h = this.screenHeight * 0.3;
const w = this.screenWidth;
const atan = Math.atan(w / 2 / h);
</code>Relationship lines are drawn by computing the distance between two role centers and rendering a line; the line length is adjusted to avoid overlapping the main character’s radius.
<code>const widthTarget = Math.sqrt(Math.pow(offsetX, 2) + Math.pow(offsetY, 2)) - (this.ROLE_SIZE / 2);
</code>Relation labels are rendered on Canvas, measured with ctx.measureText , and placed centered on the line. For text in the second and third quadrants, a scale of -1 mirrors the text to keep it readable.
<code>if (rotation < -Math.PI / 2 && rotation > (-Math.PI / 2) * 3) {
relation.scale.set(-0.5);
operation = -1;
}
</code>Animation Effects
Animations such as scaling, rotation, and movement are handled by TweenJS.
<code>const enterTween = new TWEEN.Tween(bg)
.group(this.innerGroup)
.to({ scale: { x: scaleTarget, y: scaleTarget }, rotation: 0 }, 300);
</code>Outer‑ring rotation uses a simple tween with sinusoidal easing:
<code>const tween = new TWEEN.Tween(this.outer[i])
.to({ rotation: ro }, du)
.easing(TWEEN.Easing.Sinusoidal.InOut)
.delay(5000 - du);
</code>Pitfalls and Performance
Device adaptation : A benchmark scaling factor is computed from the screen size and design dimensions to keep elements proportional on large screens such as iPad Pro.
<code>getBenchmark(w, h) {
return (innerWidth * h > innerHeight * w ? (innerHeight * w) / h : innerWidth) / 360;
}
</code>Shader compatibility : iOS Safari older versions reject the original random coefficient (437.5453). Changing it to 537.5453 fixes the cloud shader.
<code>float rand(vec2 n) {
return fract(cos(dot(n, vec2(12.9898, 4.1414))) * 437.5453);
}
</code>Outer‑ring motion : Directly animating x/y caused unnatural movement; switching to angle‑based positioning yields smoother rotation.
Performance : Profiling on high‑end (Huawei Mate30) and low‑end (Xiaomi 5s) devices shows higher GPU load on the latter, especially in red/yellow (CPU‑GPU sync) and blue (render list) sections. Continuous animation leads to heat; pausing rendering when idle can mitigate this.
Conclusion
The project demonstrates that even a complex, animation‑heavy page can be broken down into manageable pieces, emphasizing the importance of layer separation, proper scaling, shader tricks, and performance monitoring.
Yuewen Frontend Team
Click follow to learn the latest frontend insights in the cultural content industry. We welcome you to join us.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.