<![CDATA[
## 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 skills[^1], 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>`
element[^4].
2. **TypeScript**: To ensure type safety and improve code maintainability.
TypeScript is a typed superset of JavaScript that compiles to plain
JavaScript[^5].
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
repaint[^6].
4. **gl-matrix**: A high-performance matrix and vector mathematics library for
JavaScript that significantly boosts our 3D calculations[^7].
### 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:
```typescript
const updateCanvasSize = () => {
const { width, height } = navElement.getBoundingClientRect()
canvas.width = width * window.devicePixelRatio
canvas.height = height * window.devicePixelRatio
ctx.scale(window.devicePixelRatio, window.devicePixelRatio)
}
window.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:
```typescript
import { vec3 } from 'gl-matrix'
class Particle {
position: vec3
velocity: vec3
size: number
color: string
opacity: number
constructor(existingPositions: Set<string>, width: number, height: number) {
this.resetPosition(existingPositions, width, height)
this.size = Math.random() * 2 + 1.5
this.color = `hsl(${ColorManager.getRandomCyberpunkHue()}, 100%, 50%)`
this.velocity = this.initialVelocity()
this.opacity = 1
}
update(
deltaTime: number,
mouseX: number,
mouseY: number,
width: number,
height: number,
) {
// Update position based on velocity
vec3.scaleAndAdd(this.position, this.position, this.velocity, deltaTime)
// Apply forces (e.g., attraction to mouse)
if (
vec3.distance(this.position, vec3.fromValues(mouseX, mouseY, 0)) < 200
) {
vec3.add(
this.velocity,
this.velocity,
vec3.fromValues(
(mouseX - this.position[0]) * 0.00001 * deltaTime,
(mouseY - this.position[1]) * 0.00001 * deltaTime,
0,
),
)
}
// Wrap around edges
this.wrapPosition(width, height)
}
draw(ctx: CanvasRenderingContext2D, width: number, height: number) {
const projected = VectorMath.project(this.position, width, height)
ctx.fillStyle = this.color
ctx.globalAlpha = this.opacity
ctx.beginPath()
ctx.arc(
projected.x,
projected.y,
this.size * projected.scale,
0,
Math.PI * 2,
)
ctx.fill()
}
// ... other methods
}
```
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:
```typescript
import { vec3, mat4 } from 'gl-matrix'
abstract class VectorShape {
vertices: vec3[]
edges: [number, number][]
position: vec3
rotation: vec3
color: string
velocity: vec3
constructor() {
this.position = vec3.create()
this.rotation = vec3.create()
this.velocity = vec3.create()
this.color = ColorManager.getRandomCyberpunkColor()
}
abstract initializeShape(): void
update(deltaTime: number) {
// Update position and rotation
vec3.scaleAndAdd(this.position, this.position, this.velocity, deltaTime)
vec3.add(
this.rotation,
this.rotation,
vec3.fromValues(0.001 * deltaTime, 0.002 * deltaTime, 0.003 * deltaTime),
)
}
draw(ctx: CanvasRenderingContext2D, width: number, height: number) {
const modelMatrix = mat4.create()
mat4.translate(modelMatrix, modelMatrix, this.position)
mat4.rotateX(modelMatrix, modelMatrix, this.rotation[0])
mat4.rotateY(modelMatrix, modelMatrix, this.rotation[1])
mat4.rotateZ(modelMatrix, modelMatrix, this.rotation[2])
const projectedVertices = this.vertices.map((v) => {
const transformed = vec3.create()
vec3.transformMat4(transformed, v, modelMatrix)
return VectorMath.project(transformed, width, height)
})
ctx.strokeStyle = this.color
ctx.lineWidth = 2
ctx.beginPath()
this.edges.forEach(([start, end]) => {
ctx.moveTo(projectedVertices[start].x, projectedVertices[start].y)
ctx.lineTo(projectedVertices[end].x, projectedVertices[end].y)
})
ctx.stroke()
}
// ... other methods
}
```
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:
```typescript
canvas.addEventListener('mousemove', (event) => {
const rect = canvas.getBoundingClientRect()
mouseX = event.clientX - rect.left
mouseY = event.clientY - rect.top
})
// In the particle update method:
if (vec3.distance(this.position, vec3.fromValues(mouseX, mouseY, 0)) < 200) {
vec3.add(
this.velocity,
this.velocity,
vec3.fromValues(
(mouseX - this.position[0]) * 0.00001 * deltaTime,
(mouseY - this.position[1]) * 0.00001 * deltaTime,
0,
),
)
}
```
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:
```typescript
class GlitchEffect {
apply(
ctx: CanvasRenderingContext2D,
width: number,
height: number,
intensity: number,
) {
const imageData = ctx.getImageData(0, 0, width, height)
const data = imageData.data
for (let i = 0; i < data.length; i += 4) {
if (Math.random() < intensity) {
const offset = Math.floor(Math.random() * 50) * 4
data[i] = data[i + offset] || data[i]
data[i + 1] = data[i + offset + 1] || data[i + 1]
data[i + 2] = data[i + offset + 2] || data[i + 2]
}
}
ctx.putImageData(imageData, 0, 0)
}
}
```
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 counts[^8], 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 operations[^9].
```typescript
const ctx = canvas.getContext('2d')
function draw() {
// Clear the canvas
ctx.clearRect(0, 0, canvas.width, canvas.height)
// Draw background
ctx.fillStyle = 'rgba(0, 0, 0, 0.1)'
ctx.fillRect(0, 0, canvas.width, canvas.height)
// Draw particles and shapes
particlesArray.forEach((particle) => particle.draw(ctx))
shapesArray.forEach((shape) => shape.draw(ctx))
// Apply post-processing effects
glitchManager.handleGlitchEffects(ctx, width, height, timestamp)
}
```
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
development[^10], significantly reduces the load on the garbage collector,
leading to smoother animations with fewer pauses:
```typescript
class ParticlePool {
private pool: Particle[]
private maxSize: number
constructor(size: number) {
this.maxSize = size
this.pool = []
this.initialize()
}
private initialize(): void {
for (let i = 0; i < this.maxSize; i++) {
this.pool.push(new Particle(new Set<string>(), 0, 0))
}
}
public getParticle(width: number, height: number): Particle {
if (this.pool.length > 0) {
const particle = this.pool.pop()!
particle.reset(new Set<string>(), width, height)
return particle
}
return new Particle(new Set<string>(), width, height)
}
public returnParticle(particle: Particle): void {
if (this.pool.length < this.maxSize) {
this.pool.push(particle)
}
}
}
```
### 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:
```typescript
class CollisionHandler {
public static handleCollisions(
shapes: VectorShape[],
collisionCallback?: CollisionCallback,
): void {
const activeShapes = shapes.filter((shape) => !shape.isExploded)
const gridSize = 100 // Adjust based on your needs
const grid: Map<string, VectorShape[]> = new Map()
// Place shapes in grid cells
for (const shape of activeShapes) {
const cellX = Math.floor(shape.position[0] / gridSize)
const cellY = Math.floor(shape.position[1] / gridSize)
const cellZ = Math.floor(shape.position[2] / gridSize)
const cellKey = `${cellX},${cellY},${cellZ}`
if (!grid.has(cellKey)) {
grid.set(cellKey, [])
}
grid.get(cellKey)!.push(shape)
}
// Check collisions only within the same cell and neighboring cells
grid.forEach((shapesInCell, cellKey) => {
const [cellX, cellY, cellZ] = cellKey.split(',').map(Number)
for (let dx = -1; dx <= 1; dx++) {
for (let dy = -1; dy <= 1; dy++) {
for (let dz = -1; dz <= 1; dz++) {
const neighborKey = `${cellX + dx},${cellY + dy},${cellZ + dz}`
const neighborShapes = grid.get(neighborKey) || []
for (const shapeA of shapesInCell) {
for (const shapeB of neighborShapes) {
if (shapeA === shapeB) continue
const distance = vec3.distance(shapeA.position, shapeB.position)
if (distance < shapeA.radius + shapeB.radius) {
// Collision detected, handle it
this.handleCollisionResponse(shapeA, shapeB, distance)
if (collisionCallback) {
collisionCallback(shapeA, shapeB)
}
}
}
}
}
}
}
})
}
}
```
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 simulations[^11].
### 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:
```typescript
import { vec3, mat4 } from 'gl-matrix'
class VectorMath {
public static project(position: vec3, width: number, height: number) {
const fov = 500 // Field of view
const minScale = 0.5
const maxScale = 1.5
const scale = fov / (fov + position[2])
const clampedScale = Math.min(Math.max(scale, minScale), maxScale)
return {
x: position[0] * clampedScale + width / 2,
y: position[1] * clampedScale + height / 2,
scale: clampedScale,
}
}
public static rotateVertex(vertex: vec3, rotation: vec3): vec3 {
const m = mat4.create()
mat4.rotateX(m, m, rotation[0])
mat4.rotateY(m, m, rotation[1])
mat4.rotateZ(m, m, rotation[2])
const v = vec3.clone(vertex)
vec3.transformMat4(v, v, m)
return v
}
}
```
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 rate[^12]:
```typescript
let lastTime = 0
function animateCyberScape(timestamp: number) {
const deltaTime = timestamp - lastTime
if (deltaTime < config.frameTime) {
animationFrameId = requestAnimationFrame(animateCyberScape)
return
}
lastTime = timestamp
// Update logic
updateParticles(deltaTime)
updateShapes(deltaTime)
// Render
draw()
// Schedule next frame
animationFrameId = requestAnimationFrame(animateCyberScape)
}
// Start the animation loop
requestAnimationFrame(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 timing[^13].
### 6. Lazy Initialization and Delayed Appearance
To improve initial load times and create a more dynamic scene, we implement lazy
initialization for particles:
```typescript
class Particle {
// ... other properties
private appearanceDelay: number
private isVisible: boolean
constructor() {
// ... other initializations
this.setDelayedAppearance()
}
setDelayedAppearance() {
this.appearanceDelay = Math.random() * 5000 // Random delay up to 5 seconds
this.isVisible = false
}
updateDelay(deltaTime: number) {
if (!this.isVisible) {
this.appearanceDelay -= deltaTime
if (this.appearanceDelay <= 0) {
this.isVisible = true
}
}
}
draw(ctx: CanvasRenderingContext2D) {
if (this.isVisible) {
// Actual drawing logic
}
}
}
// In the main update loop
particlesArray.forEach((particle) => {
particle.updateDelay(deltaTime)
if (particle.isVisible) {
particle.update(deltaTime)
}
})
```
This technique, known as lazy loading[^14], 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:
```typescript
class CyberScapeConfig {
// ... other properties and methods
public calculateParticleCount(width: number, height: number): number {
const isMobile = width <= this.mobileWidthThreshold
let count = Math.max(
this.baseParticleCount,
Math.floor(width * height * this.particlesPerPixel),
)
if (isMobile) {
count = Math.floor(count * this.mobileParticleReductionFactor)
}
return count
}
public getShapeCount(width: number): number {
return width <= this.mobileWidthThreshold
? this.numberOfShapesMobile
: this.numberOfShapes
}
}
// In the main initialization and resize handler
function adjustParticleCount() {
const config = CyberScapeConfig.getInstance()
numberOfParticles = config.calculateParticleCount(width, height)
numberOfShapes = config.getShapeCount(width)
// Adjust particle array size
while (particlesArray.length < numberOfParticles) {
particlesArray.push(particlePool.getParticle(width, height))
}
particlesArray.length = numberOfParticles
// Adjust shape array size
while (shapesArray.length < numberOfShapes) {
shapesArray.push(ShapeFactory.createShape(/* ... */))
}
shapesArray.length = numberOfShapes
}
window.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 optimization[^15].
## 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
[^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.
]]>