Creative Coding: The Birth of CyberScape

9/30/2024•Stefanie Jane
creative codingdemosceneWebGLgl-matrixoptimizationTypeScriptCanvas APIcyberpunkinteractive animation

Introduction

As a developer with roots in the 8-bit demoscene, I've always been fascinated by the art of pushing hardware to its limits to create stunning visual effects. The demoscene, a computer art subculture that produces demos (audio-visual presentations) to showcase programming, artistic, and musical skills1, taught me the importance of optimization, creativity within constraints, and the sheer joy of making computers do unexpected things.

When I set out to redesign my personal website, hyperbliss.tech, I wanted to capture that same spirit of innovation and visual spectacle, but with a modern twist. This desire led to the creation of CyberScape, an interactive canvas-based animation that brings the header of my website to life.

CyberScape is more than just eye candy; it's a testament to the evolution of computer graphics, from the days of 8-bit machines to the powerful browsers we have today. In this post, I'll take you through the journey of developing CyberScape, explaining how it works, the challenges faced, and the techniques used to optimize its performance.

The Vision

The concept for CyberScape was born from a desire to create a dynamic, cyberpunk-inspired backdrop that would not only look visually appealing but also respond to user interactions. I envisioned a space filled with glowing particles and geometric shapes, all moving in a 3D space and reacting to mouse movements. This animation would serve as more than just eye candy; it would be an integral part of the site's identity, setting the tone for the tech-focused and creative content to follow.

The aesthetic draws inspiration from classic cyberpunk works like William Gibson's "Neuromancer"2 and the visual style of films like "Blade Runner"3, blending them with the neon-soaked digital landscapes popularized in modern interpretations of the genre.

The Technical Approach

Core Technologies

CyberScape is built using the following technologies:

  1. HTML5 Canvas: For rendering the animation efficiently. The Canvas API provides a means for drawing graphics via JavaScript and the HTML <canvas> element4.
  2. TypeScript: To ensure type safety and improve code maintainability. TypeScript is a typed superset of JavaScript that compiles to plain JavaScript5.
  3. requestAnimationFrame: For smooth, optimized animation loops. This method tells the browser that you wish to perform an animation and requests that the browser calls a specified function to update an animation before the next repaint6.
  4. gl-matrix: A high-performance matrix and vector mathematics library for JavaScript that significantly boosts our 3D calculations7.

Key Components

The animation consists of several key components:

  1. Particles: Small, glowing dots that move around the canvas, creating a sense of depth and movement.
  2. Vector Shapes: Larger geometric shapes (cubes, pyramids, etc.) that float in the 3D space, adding structure and complexity to the scene.
  3. Glitch Effects: Occasional visual distortions to enhance the cyberpunk aesthetic and add dynamism to the animation.
  4. Color Management: A system for handling color transitions and blending, creating a vibrant and cohesive visual experience.
  5. Collision Detection: An optimized system for detecting and handling interactions between shapes and particles.
  6. Force Handlers: Modules that manage attraction, repulsion, and other forces acting on shapes and particles.

The Development Process

1. Setting Up the Canvas

The first step was to create a canvas element that would cover the header area of the site. This canvas needed to be responsive, adjusting its size when the browser window is resized:

1const updateCanvasSize = () => {
2  const { width, height } = navElement.getBoundingClientRect();
3  canvas.width = width * window.devicePixelRatio;
4  canvas.height = height * window.devicePixelRatio;
5  ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
6};
7
8window.addEventListener("resize", updateCanvasSize);

This code ensures that the canvas always matches the size of its container and looks crisp on high-DPI displays.

2. Creating the Particle System

The particle system is the heart of CyberScape. Each particle is an instance of a Particle class, which manages its position, velocity, and appearance. With the integration of gl-matrix, we've optimized our vector operations:

1import { vec3 } from "gl-matrix";
2
3class Particle {
4  position: vec3;
5  velocity: vec3;
6  size: number;
7  color: string;
8  opacity: number;
9
10  constructor(existingPositions: Set<string>, width: number, height: number) {
11    this.resetPosition(existingPositions, width, height);
12    this.size = Math.random() * 2 + 1.5;
13    this.color = `hsl(${ColorManager.getRandomCyberpunkHue()}, 100%, 50%)`;
14    this.velocity = this.initialVelocity();
15    this.opacity = 1;
16  }
17
18  update(
19    deltaTime: number,
20    mouseX: number,
21    mouseY: number,
22    width: number,
23    height: number
24  ) {
25    // Update position based on velocity
26    vec3.scaleAndAdd(this.position, this.position, this.velocity, deltaTime);
27
28    // Apply forces (e.g., attraction to mouse)
29    if (
30      vec3.distance(this.position, vec3.fromValues(mouseX, mouseY, 0)) < 200
31    ) {
32      vec3.add(
33        this.velocity,
34        this.velocity,
35        vec3.fromValues(
36          (mouseX - this.position[0]) * 0.00001 * deltaTime,
37          (mouseY - this.position[1]) * 0.00001 * deltaTime,
38          0
39        )
40      );
41    }
42
43    // Wrap around edges
44    this.wrapPosition(width, height);
45  }
46
47  draw(ctx: CanvasRenderingContext2D, width: number, height: number) {
48    const projected = VectorMath.project(this.position, width, height);
49    ctx.fillStyle = this.color;
50    ctx.globalAlpha = this.opacity;
51    ctx.beginPath();
52    ctx.arc(
53      projected.x,
54      projected.y,
55      this.size * projected.scale,
56      0,
57      Math.PI * 2
58    );
59    ctx.fill();
60  }
61
62  // ... other methods
63}

This implementation allows for efficient updating and rendering of thousands of particles, creating the illusion of a vast, dynamic space. The use of gl-matrix's vec3 operations significantly improves performance for vector calculations.

3. Implementing Vector Shapes

To add more visual interest, we created a VectorShape class to represent larger geometric objects. With gl-matrix, we've enhanced our 3D transformations:

1import { vec3, mat4 } from "gl-matrix";
2
3abstract class VectorShape {
4  vertices: vec3[];
5  edges: [number, number][];
6  position: vec3;
7  rotation: vec3;
8  color: string;
9  velocity: vec3;
10
11  constructor() {
12    this.position = vec3.create();
13    this.rotation = vec3.create();
14    this.velocity = vec3.create();
15    this.color = ColorManager.getRandomCyberpunkColor();
16  }
17
18  abstract initializeShape(): void;
19
20  update(deltaTime: number) {
21    // Update position and rotation
22    vec3.scaleAndAdd(this.position, this.position, this.velocity, deltaTime);
23
24    vec3.add(
25      this.rotation,
26      this.rotation,
27      vec3.fromValues(0.001 * deltaTime, 0.002 * deltaTime, 0.003 * deltaTime)
28    );
29  }
30
31  draw(ctx: CanvasRenderingContext2D, width: number, height: number) {
32    const modelMatrix = mat4.create();
33    mat4.translate(modelMatrix, modelMatrix, this.position);
34    mat4.rotateX(modelMatrix, modelMatrix, this.rotation[0]);
35    mat4.rotateY(modelMatrix, modelMatrix, this.rotation[1]);
36    mat4.rotateZ(modelMatrix, modelMatrix, this.rotation[2]);
37
38    const projectedVertices = this.vertices.map((v) => {
39      const transformed = vec3.create();
40      vec3.transformMat4(transformed, v, modelMatrix);
41      return VectorMath.project(transformed, width, height);
42    });
43
44    ctx.strokeStyle = this.color;
45    ctx.lineWidth = 2;
46    ctx.beginPath();
47    this.edges.forEach(([start, end]) => {
48      ctx.moveTo(projectedVertices[start].x, projectedVertices[start].y);
49      ctx.lineTo(projectedVertices[end].x, projectedVertices[end].y);
50    });
51    ctx.stroke();
52  }
53
54  // ... other methods
55}

This abstract class serves as a base for specific shape implementations like CubeShape, PyramidShape, etc. These shapes add depth and structure to the scene, creating a more complex and engaging visual environment. The use of gl-matrix's matrix operations (mat4) significantly improves the efficiency of our 3D transformations.

4. Adding Interactivity

To make CyberScape responsive to user input, we implemented mouse tracking and used the cursor position to influence particle movement:

1canvas.addEventListener("mousemove", (event) => {
2  const rect = canvas.getBoundingClientRect();
3  mouseX = event.clientX - rect.left;
4  mouseY = event.clientY - rect.top;
5});
6
7// In the particle update method:
8if (vec3.distance(this.position, vec3.fromValues(mouseX, mouseY, 0)) < 200) {
9  vec3.add(
10    this.velocity,
11    this.velocity,
12    vec3.fromValues(
13      (mouseX - this.position[0]) * 0.00001 * deltaTime,
14      (mouseY - this.position[1]) * 0.00001 * deltaTime,
15      0
16    )
17  );
18}

This creates a subtle interactive effect where particles are gently attracted to the user's cursor, adding an engaging layer of responsiveness to the animation.

5. Implementing Glitch Effects

To enhance the cyberpunk aesthetic, we added occasional glitch effects using pixel manipulation:

1class GlitchEffect {
2  apply(
3    ctx: CanvasRenderingContext2D,
4    width: number,
5    height: number,
6    intensity: number
7  ) {
8    const imageData = ctx.getImageData(0, 0, width, height);
9    const data = imageData.data;
10
11    for (let i = 0; i < data.length; i += 4) {
12      if (Math.random() < intensity) {
13        const offset = Math.floor(Math.random() * 50) * 4;
14        data[i] = data[i + offset] || data[i];
15        data[i + 1] = data[i + offset + 1] || data[i + 1];
16        data[i + 2] = data[i + offset + 2] || data[i + 2];
17      }
18    }
19
20    ctx.putImageData(imageData, 0, 0);
21  }
22}

This effect is applied periodically to create brief moments of visual distortion, reinforcing the digital, glitchy nature of the cyberpunk world we're creating.

Performance Optimizations

Creating a visually stunning and interactive animation is one thing, but making it run smoothly across various devices and browsers is another challenge entirely. In the spirit of the demoscene, where every CPU cycle and byte of memory counts8, we approached CyberScape with a relentless focus on performance. Here's an in-depth look at the optimization techniques employed to make CyberScape a reality.

1. Efficient Rendering with Canvas

The choice of using the HTML5 Canvas API was deliberate. Canvas provides a low-level, immediate mode rendering API that allows for highly optimized 2D drawing operations9.

1const ctx = canvas.getContext("2d");
2
3function draw() {
4  // Clear the canvas
5  ctx.clearRect(0, 0, canvas.width, canvas.height);
6
7  // Draw background
8  ctx.fillStyle = "rgba(0, 0, 0, 0.1)";
9  ctx.fillRect(0, 0, canvas.width, canvas.height);
10
11  // Draw particles and shapes
12  particlesArray.forEach((particle) => particle.draw(ctx));
13  shapesArray.forEach((shape) => shape.draw(ctx));
14
15  // Apply post-processing effects
16  glitchManager.handleGlitchEffects(ctx, width, height, timestamp);
17}

By carefully managing our draw calls and using appropriate Canvas API methods, we ensure efficient rendering of our complex scene.

2. Object Pooling for Particle System

To avoid garbage collection pauses and reduce memory allocation overhead, we implement an object pool for particles. This technique, commonly used in game development10, significantly reduces the load on the garbage collector, leading to smoother animations with fewer pauses:

1class ParticlePool {
2  private pool: Particle[];
3  private maxSize: number;
4
5  constructor(size: number) {
6    this.maxSize = size;
7    this.pool = [];
8    this.initialize();
9  }
10
11  private initialize(): void {
12    for (let i = 0; i < this.maxSize; i++) {
13      this.pool.push(new Particle(new Set<string>(), 0, 0));
14    }
15  }
16
17  public getParticle(width: number, height: number): Particle {
18    if (this.pool.length > 0) {
19      const particle = this.pool.pop()!;
20      particle.reset(new Set<string>(), width, height);
21      return particle;
22    }
23    return new Particle(new Set<string>(), width, height);
24  }
25
26  public returnParticle(particle: Particle): void {
27    if (this.pool.length < this.maxSize) {
28      this.pool.push(particle);
29    }
30  }
31}

3. Optimized Collision Detection

We optimize our collision detection by using a grid-based spatial partitioning system, which significantly reduces the number of collision checks needed:

1class CollisionHandler {
2  public static handleCollisions(
3    shapes: VectorShape[],
4    collisionCallback?: CollisionCallback
5  ): void {
6    const activeShapes = shapes.filter((shape) => !shape.isExploded);
7    const gridSize = 100; // Adjust based on your needs
8    const grid: Map<string, VectorShape[]> = new Map();
9
10    // Place shapes in grid cells
11    for (const shape of activeShapes) {
12      const cellX = Math.floor(shape.position[0] / gridSize);
13      const cellY = Math.floor(shape.position[1] / gridSize);
14      const cellZ = Math.floor(shape.position[2] / gridSize);
15      const cellKey = `${cellX},${cellY},${cellZ}`;
16
17      if (!grid.has(cellKey)) {
18        grid.set(cellKey, []);
19      }
20      grid.get(cellKey)!.push(shape);
21    }
22
23    // Check collisions only within the same cell and neighboring cells
24    grid.forEach((shapesInCell, cellKey) => {
25      const [cellX, cellY, cellZ] = cellKey.split(",").map(Number);
26
27      for (let dx = -1; dx <= 1; dx++) {
28        for (let dy = -1; dy <= 1; dy++) {
29          for (let dz = -1; dz <= 1; dz++) {
30            const neighborKey = `${cellX + dx},${cellY + dy},${cellZ + dz}`;
31            const neighborShapes = grid.get(neighborKey) || [];
32
33            for (const shapeA of shapesInCell) {
34              for (const shapeB of neighborShapes) {
35                if (shapeA === shapeB) continue;
36
37                const distance = vec3.distance(
38                  shapeA.position,
39                  shapeB.position
40                );
41
42                if (distance < shapeA.radius + shapeB.radius) {
43                  // Collision detected, handle it
44                  this.handleCollisionResponse(shapeA, shapeB, distance);
45
46                  if (collisionCallback) {
47                    collisionCallback(shapeA, shapeB);
48                  }
49                }
50              }
51            }
52          }
53        }
54      }
55    });
56  }
57}

This approach ensures that we only perform expensive collision resolution calculations when shapes are actually close to each other, a common optimization technique in real-time simulations11.

4. Efficient Math Operations with gl-matrix

One of the most significant optimizations we've implemented is the use of gl-matrix for our vector and matrix operations. This high-performance mathematics library is specifically designed for WebGL applications, but it's equally beneficial for our Canvas-based animation:

1import { vec3, mat4 } from "gl-matrix";
2
3class VectorMath {
4  public static project(position: vec3, width: number, height: number) {
5    const fov = 500; // Field of view
6    const minScale = 0.5;
7    const maxScale = 1.5;
8    const scale = fov / (fov + position[2]);
9    const clampedScale = Math.min(Math.max(scale, minScale), maxScale);
10    return {
11      x: position[0] * clampedScale + width / 2,
12      y: position[1] * clampedScale + height / 2,
13      scale: clampedScale,
14    };
15  }
16
17  public static rotateVertex(vertex: vec3, rotation: vec3): vec3 {
18    const m = mat4.create();
19    mat4.rotateX(m, m, rotation[0]);
20    mat4.rotateY(m, m, rotation[1]);
21    mat4.rotateZ(m, m, rotation[2]);
22
23    const v = vec3.clone(vertex);
24    vec3.transformMat4(v, v, m);
25    return v;
26  }
27}

By using gl-matrix, we benefit from highly optimized vector and matrix operations that are often faster than native JavaScript math operations. This is particularly important for our 3D transformations and projections, which are performed frequently in the animation loop.

5. Render Loop Optimization

We use requestAnimationFrame for the main render loop, ensuring smooth animation that's in sync with the browser's refresh rate12:

1let lastTime = 0;
2
3function animateCyberScape(timestamp: number) {
4  const deltaTime = timestamp - lastTime;
5  if (deltaTime < config.frameTime) {
6    animationFrameId = requestAnimationFrame(animateCyberScape);
7    return;
8  }
9  lastTime = timestamp;
10
11  // Update logic
12  updateParticles(deltaTime);
13  updateShapes(deltaTime);
14
15  // Render
16  draw();
17
18  // Schedule next frame
19  animationFrameId = requestAnimationFrame(animateCyberScape);
20}
21
22// Start the animation loop
23requestAnimationFrame(animateCyberScape);

This approach allows us to maintain a consistent frame rate while efficiently updating and rendering our scene. By using deltaTime, we ensure that our animations remain smooth even if some frames take longer to process, a technique known as delta timing13.

6. Lazy Initialization and Delayed Appearance

To improve initial load times and create a more dynamic scene, we implement lazy initialization for particles:

1class Particle {
2  // ... other properties
3  private appearanceDelay: number;
4  private isVisible: boolean;
5
6  constructor() {
7    // ... other initializations
8    this.setDelayedAppearance();
9  }
10
11  setDelayedAppearance() {
12    this.appearanceDelay = Math.random() * 5000; // Random delay up to 5 seconds
13    this.isVisible = false;
14  }
15
16  updateDelay(deltaTime: number) {
17    if (!this.isVisible) {
18      this.appearanceDelay -= deltaTime;
19      if (this.appearanceDelay <= 0) {
20        this.isVisible = true;
21      }
22    }
23  }
24
25  draw(ctx: CanvasRenderingContext2D) {
26    if (this.isVisible) {
27      // Actual drawing logic
28    }
29  }
30}
31
32// In the main update loop
33particlesArray.forEach((particle) => {
34  particle.updateDelay(deltaTime);
35  if (particle.isVisible) {
36    particle.update(deltaTime);
37  }
38});

This technique, known as lazy loading14, allows us to gradually introduce particles into the scene, reducing the initial computational load and creating a more engaging visual effect. It's particularly useful for improving perceived performance on slower devices.

7. Adaptive Performance Adjustments

We implement an adaptive quality system that adjusts the number of particles and shapes based on the window size and device capabilities:

1class CyberScapeConfig {
2  // ... other properties and methods
3
4  public calculateParticleCount(width: number, height: number): number {
5    const isMobile = width <= this.mobileWidthThreshold;
6    let count = Math.max(
7      this.baseParticleCount,
8      Math.floor(width * height * this.particlesPerPixel)
9    );
10    if (isMobile) {
11      count = Math.floor(count * this.mobileParticleReductionFactor);
12    }
13    return count;
14  }
15
16  public getShapeCount(width: number): number {
17    return width <= this.mobileWidthThreshold
18      ? this.numberOfShapesMobile
19      : this.numberOfShapes;
20  }
21}
22
23// In the main initialization and resize handler
24function adjustParticleCount() {
25  const config = CyberScapeConfig.getInstance();
26  numberOfParticles = config.calculateParticleCount(width, height);
27  numberOfShapes = config.getShapeCount(width);
28
29  // Adjust particle array size
30  while (particlesArray.length < numberOfParticles) {
31    particlesArray.push(particlePool.getParticle(width, height));
32  }
33  particlesArray.length = numberOfParticles;
34
35  // Adjust shape array size
36  while (shapesArray.length < numberOfShapes) {
37    shapesArray.push(ShapeFactory.createShape(/* ... */));
38  }
39  shapesArray.length = numberOfShapes;
40}
41
42window.addEventListener("resize", adjustParticleCount);

This ensures that the visual density of particles and shapes remains consistent across different screen sizes while also adapting to device capabilities. This type of dynamic content adjustment is a common technique in responsive web design and performance optimization15.

Challenges and Lessons Learned

Developing CyberScape wasn't without its challenges. Here are some of the key issues I faced and the lessons learned:

  1. Performance Bottlenecks: Initially, the animation would stutter on mobile devices. Profiling the code revealed that the particle update loop and collision detection were the culprits. By implementing object pooling, spatial partitioning for collision detection, and adaptive quality settings, I was able to significantly improve performance across all devices. The introduction of gl-matrix for vector and matrix operations provided an additional performance boost.

  2. Browser Compatibility: Different browsers handle canvas rendering slightly differently, especially when it comes to blending modes and color spaces. I had to carefully test and adjust the rendering code to ensure consistent visuals across browsers. Using the ColorManager class helped standardize color operations across the project.

  3. Memory Management: Long running animations can lead to memory leaks if not carefully managed. Implementing object pooling, ensuring proper cleanup of event listeners, and using efficient data structures were crucial in maintaining stable performance over time. The use of gl-matrix's stack-allocated vectors and matrices also helped in reducing garbage collection pauses.

  4. Balancing Visuals and Performance: It was tempting to keep adding more visual elements, but each addition came at a performance cost. Finding the right balance between visual complexity and smooth performance was an ongoing challenge. The adaptive quality system helped in maintaining this balance across different devices.

  5. Responsive Design: Ensuring that the animation looked good and performed well on everything from large desktop monitors to small mobile screens required careful consideration of scaling and adaptive quality settings. The CyberScapeConfig class became instrumental in managing these adaptations.

  6. Code Organization: As the project grew, maintaining a clean and organized codebase became increasingly important. Adopting a modular structure with classes like ParticlePool, ShapeFactory, and VectorMath helped in keeping the code manageable and extensible. The integration of gl-matrix required some refactoring but ultimately led to cleaner, more efficient code.

These challenges echoed many of the limitations I used to face in the demoscene, where working within strict hardware constraints was the norm. It was a reminder that even with modern web technologies, efficient coding practices and performance considerations are still crucial.

Conclusion

The development of CyberScape has been a thrilling journey, blending the spirit of the 8-bit demoscene with the power of modern web technologies. Through careful optimization and creative problem-solving, we've created a visually stunning and performant animation that pushes the boundaries of what's possible in a web browser.

The techniques employed in CyberScape—from efficient Canvas rendering and object pooling to optimized collision detection and the use of gl-matrix for high-performance math operations—demonstrate that with thoughtful optimization, we can create complex, interactive graphics that run smoothly even on modest hardware.

As we continue to refine and expand CyberScape, we're excited about the possibilities for future enhancements. Perhaps we'll incorporate WebGL for GPU-accelerated rendering, implement more advanced spatial partitioning for collision detection, or explore Web Workers for offloading heavy computations.

The modular structure we've implemented, with classes like Particle, VectorShape, ColorManager, and GlitchEffect, provides a solid foundation for future improvements and extensions. This modularity not only makes the code more maintainable but also allows for easier experimentation with new features and optimizations.

The world of web development is constantly evolving, and projects like CyberScape serve as a bridge between the innovative spirit of the demoscene and the cutting-edge capabilities of modern browsers. As we push these technologies to their limits, we're not just creating visually stunning experiences—we're carrying forward the legacy of digital creativity that has driven computer graphics for decades.

References

Footnotes

  1. Polgár, T. (2005). Freax: The Brief History of the Computer Demoscene. CSW-Verlag. ↩

  2. Gibson, W. (1984). Neuromancer. Ace. ↩

  3. Scott, R. (Director). (1982). Blade Runner [Film]. Warner Bros. ↩

  4. Mozilla Developer Network. (2023). Canvas API. https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API ↩

  5. TypeScript. (2023). TypeScript Documentation. https://www.typescriptlang.org/docs/ ↩

  6. Mozilla Developer Network. (2023). window.requestAnimationFrame(). https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame ↩

  7. gl-matrix. (2023). gl-matrix Documentation. http://glmatrix.net/docs/ ↩

  8. Reunanen, M. (2017). Times of Change in the Demoscene: A Creative Community and Its Relationship with Technology. University of Turku. ↩

  9. Fulton, S., & Fulton, J. (2013). HTML5 Canvas: Native Interactivity and Animation for the Web. O'Reilly Media. ↩

  10. Nystrom, R. (2014). Game Programming Patterns. Genever Benning. ↩

  11. Ericson, C. (2004). Real-Time Collision Detection. Morgan Kaufmann. ↩

  12. Grigorik, I. (2013). High Performance Browser Networking. O'Reilly Media. ↩

  13. LaMothe, A. (1999). Tricks of the Windows Game Programming Gurus. Sams. ↩

  14. Osmani, A. (2020). Learning Patterns. https://www.patterns.dev/posts/lazy-loading-pattern/ ↩

  15. Marcotte, E. (2011). Responsive Web Design. A Book Apart. ↩