Skip to content

HTML5 Canvas 2D API to WebGL Conversion Notes #2

@jasonbra1n

Description

@jasonbra1n

Root Folder version is running canvas drawing lots of lines with and is CPU intensive. Folder /3/ is to be re-organized as a development repository to work on a Web GL version that performs much better by using the GPU.

From Grok Notes:

To address your question about how your spiral design tool script was working before the addition of WebGL, and why it was performing slowly, let’s break this down systematically. Since this project is the Kathara Spiral generator, I’ll assume the pre-WebGL version used a different rendering method, likely HTML5 Canvas with 2D context (CanvasRenderingContext2D) or even raw DOM manipulation (e.g., SVG or drawing with <div> elements). I’ll analyze the likely implementation, explain why it was slow, and contrast it with the current WebGL implementation to highlight the performance improvements. This will serve as a useful note for your project documentation, helping you track the evolution of the tool.


How the Script Was Working Before WebGL

Before WebGL was introduced, your script likely used the HTML5 Canvas 2D API (CanvasRenderingContext2D) to draw the spirals. This is a common approach for 2D graphics in the browser when WebGL isn’t used, especially for a project like a spiral generator that involves drawing geometric shapes. Here’s a breakdown of how it probably worked:

1. Canvas Setup

  • The script would have used an HTML <canvas> element, similar to the current setup, but instead of initializing a WebGL context (canvas.getContext('webgl')), it would have used the 2D context:
    const canvas = document.getElementById('spiralCanvas');
    const ctx = canvas.getContext('2d');
  • The canvas would be resized to match the window dimensions, just like in your current script:
    function resizeCanvas() {
      canvas.width = window.innerWidth;
      canvas.height = window.innerHeight;
    }
    window.addEventListener('resize', resizeCanvas);
    resizeCanvas();

2. Drawing the Spiral

  • The spiral would have been drawn by calculating the positions of points along the spiral path and using 2D drawing commands like ctx.beginPath(), ctx.moveTo(), and ctx.lineTo() to connect them.
  • A simplified version of the drawing function might have looked like this:
    function drawSpiralOnContext(ctx, width, height, params) {
      ctx.clearRect(0, 0, width, height); // Clear the canvas
      ctx.fillStyle = params.backgroundColor;
      ctx.fillRect(0, 0, width, height); // Draw background
    
      const centerX = width / 2;
      const centerY = height / 2;
    
      for (let l = 0; l < params.layers; l++) {
        const currentScale = params.scale * Math.pow(params.layerRatio / 5, l);
        const initialAngle = (params.rotation + (l * 10)) * (Math.PI / 180);
    
        drawSpiralPath(ctx, centerX, centerY, params, initialAngle, currentScale, false, false, params.strokeColor);
        // Handle mirrors (vertical, horizontal, both) similarly to current script
      }
    }
    
    function drawSpiralPath(ctx, centerX, centerY, params, initialAngle, currentScale, mirrorX, mirrorY, color) {
      let angle = initialAngle;
      let prevX = centerX;
      let prevY = centerY;
    
      ctx.beginPath();
      ctx.moveTo(centerX, centerY);
      ctx.strokeStyle = color;
      ctx.lineWidth = params.lineWidth;
      ctx.globalAlpha = params.opacity;
    
      for (let i = 1; i < params.nodes; i++) {
        let r = params.spiralType === 'linear' ? currentScale * i : currentScale * Math.exp(0.1 * i);
        let x = centerX + Math.cos(angle) * r;
        let y = centerY + Math.sin(angle) * r;
    
        if (mirrorX) x = centerX * 2 - x;
        if (mirrorY) y = centerY * 2 - y;
    
        ctx.lineTo(x, y);
    
        prevX = x;
        prevY = y;
        angle += Math.PI / 3; // Adjust for heptagonal or hexagonal spirals
      }
    
      ctx.stroke();
    }
  • Key Features:
    • The spiral was drawn as a single continuous path using ctx.lineTo() to connect points.
    • Effects like line width, opacity, and color were applied directly via 2D context properties (ctx.lineWidth, ctx.globalAlpha, ctx.strokeStyle).
    • Mirroring (vertical, horizontal, or both) was handled by adjusting coordinates and redrawing the path with different colors.

3. Handling Effects

  • Gradient Stroke: If you had a gradient stroke, you’d create a CanvasGradient object and apply it:
    const gradient = ctx.createLinearGradient(centerX, centerY, endX, endY);
    gradient.addColorStop(0, params.strokeColor);
    gradient.addColorStop(1, '#000000');
    ctx.strokeStyle = gradient;
  • Dashed Lines: You’d use ctx.setLineDash():
    if (params.dashEffect) {
      ctx.setLineDash([5, 5]);
    } else {
      ctx.setLineDash([]);
    }
  • Curved Lines: For curved lines, you might have used ctx.quadraticCurveTo() or ctx.bezierCurveTo() to smooth the transitions between points, similar to the current WebGL implementation but in 2D.

4. Rendering Loop

  • The script would redraw the entire spiral whenever a parameter changed (e.g., scale, rotation, or nodes). This was likely tied to input event listeners:
    document.querySelectorAll('input, select').forEach(input => {
      input.addEventListener('input', () => {
        updateParams();
        drawSpiral();
      });
    });
  • For animations like auto-rotation, you’d use requestAnimationFrame to update the rotation and redraw the canvas each frame.

Why Was It Performing Slowly?

The pre-WebGL version using the 2D Canvas API was likely performing slowly due to several inherent limitations of the 2D context and the way the script was structured. Let’s break down the reasons:

1. Software Rendering in 2D Canvas

  • The 2D Canvas API (CanvasRenderingContext2D) is typically not hardware-accelerated in the same way WebGL is. While modern browsers do use some GPU acceleration for 2D Canvas, the rendering pipeline is less optimized:
    • Drawing operations like ctx.lineTo() and ctx.stroke() are executed on the CPU, with the final rasterization sometimes offloaded to the GPU.
    • Complex paths (e.g., a spiral with many nodes, layers, and mirrors) require the CPU to compute each segment, which can be slow for large numbers of points.
  • In your project, if params.nodes was high (e.g., 50 nodes per layer) and params.layers was also high (e.g., 80 layers, as in the denseMirror preset), the script had to compute and draw thousands of points per frame, leading to significant CPU overhead.

2. Redrawing the Entire Canvas

  • The 2D Canvas API doesn’t retain a scene graph—it’s a stateless, immediate-mode API. Every time you wanted to update the spiral (e.g., for rotation or parameter changes), you had to:
    • Clear the entire canvas (ctx.clearRect()).
    • Redraw the background (ctx.fillRect()).
    • Redraw all layers, nodes, and mirrored versions of the spiral.
  • This full redraw was necessary even for small changes, like adjusting the rotation by 1 degree during auto-rotation. For a complex spiral with multiple layers and mirrors, this meant re-computing and re-drawing thousands of line segments per frame, which is computationally expensive.

3. Inefficient Path Construction

  • Drawing a single continuous path with ctx.beginPath() and ctx.lineTo() for the entire spiral meant that the 2D context had to handle a very long path:
    • For a spiral with 50 nodes and 80 layers, that’s 50 × 80 = 4,000 points per spiral, plus additional points for mirrored versions.
    • The 2D Canvas API struggles with very long paths, as it needs to rasterize the entire path in one go when calling ctx.stroke().
  • Effects like dashed lines (ctx.setLineDash()) or gradients further slowed things down, as they add complexity to the rasterization process.

4. Lack of Parallelization

  • The 2D Canvas API doesn’t take full advantage of the GPU’s parallel processing capabilities. Operations like stroking a path, applying gradients, or handling thick lines (ctx.lineWidth) are processed sequentially on the CPU, leading to bottlenecks.
  • For thick lines (e.g., lineWidth: 10), the 2D Canvas API has to compute the outline of the path and fill it, which is computationally expensive, especially for complex shapes with sharp angles (like a heptagonal spiral).

5. Animation Overhead

  • If you had features like auto-rotation or audio reactivity, the script was redrawing the spiral at 60 frames per second (via requestAnimationFrame). This meant performing all the above computations 60 times per second, which quickly overwhelmed the CPU, especially on lower-end devices or with complex spirals.
  • For example, with auto-rotation enabled, the script would:
    • Update the rotation angle.
    • Clear the canvas.
    • Redraw all layers and mirrors.
    • Repeat every frame.
      This constant redrawing without optimization led to noticeable lag or stuttering.

6. Specific to Your Project

  • High Node and Layer Counts: Presets like denseMirror (50 nodes, 80 layers) or goldenSpiral (50 nodes, 50 layers) resulted in thousands of points to draw, exacerbating the performance issues.
  • Mirroring: Drawing mirrored versions (vertical, horizontal, both) meant redrawing the spiral up to four times per frame, each with its own path and effects.
  • Effects: Features like gradients, dashed lines, and curved lines added extra overhead. For example, computing a gradient for a long path or applying quadraticCurveTo() for curved lines increased the CPU load.

Why WebGL Improved Performance

Switching to WebGL addressed these performance issues by leveraging the GPU’s parallel processing capabilities and a more efficient rendering pipeline. Here’s how the current WebGL implementation (as seen in your script.js) improves on the 2D Canvas version:

1. Hardware Acceleration

  • WebGL uses the GPU to perform rendering, offloading most of the work from the CPU.
  • Vertex and fragment shaders (like the ones in your script) run on the GPU, allowing parallel processing of vertices and pixels. For example:
    • The vertex shader processes all spiral points in parallel.
    • The fragment shader applies effects like gradients or tapering at the pixel level, also in parallel.

2. Efficient Geometry Handling

  • In the WebGL version, the spiral is represented as a series of vertices stored in buffers (positionBuffer, distanceBuffer, etc.):
    gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);
  • These buffers are sent to the GPU once and reused for each frame, reducing the CPU’s workload. In contrast, the 2D Canvas version recomputed the entire path every frame.

3. Shader-Based Effects

  • Effects like gradients, tapering, and rounded corners are handled in the fragment shader, which runs on the GPU:
    if (u_gradientEnabled == 1) {
      float t = v_distance / u_maxDistance;
      color.rgb = mix(color.rgb, vec3(0.0), t);
    }
  • This is much faster than applying gradients or dashing in the 2D Canvas API, which relies on CPU-based computations.

4. Reduced Redraw Overhead

  • With WebGL, you only need to update the vertex buffers when the spiral’s geometry changes (e.g., when params.nodes or params.layers changes). For animations like rotation, you can update a uniform (e.g., a rotation matrix) without recomputing the entire spiral:
    • In the 2D Canvas version, even a small rotation change required a full redraw.
    • In WebGL, you can update a uniform and let the GPU handle the transformation, though your current script still recomputes the spiral for simplicity.

5. Thick Lines with Triangles

  • The WebGL version draws thick lines by generating quads (or triangles) for each line segment, as seen in generateThickLineVertices. This is more efficient than the 2D Canvas API’s ctx.lineWidth, which struggles with thick lines and sharp angles:
    const { vertices, normals } = generateThickLineVertices(
      prevX, prevY, x, y, baseWidth, i === 1, isLastSegment, params.lineEndStyle
    );
  • The GPU renders these quads in parallel, avoiding the CPU bottleneck of the 2D Canvas API.

6. Scalability

  • WebGL scales better with complexity. For example, drawing a spiral with 50 nodes and 80 layers (4,000 points) is much faster in WebGL because the GPU can handle thousands of vertices and fragments simultaneously, whereas the 2D Canvas API processes them sequentially.

Comparison: Pre-WebGL vs. WebGL

Aspect Pre-WebGL (2D Canvas) WebGL (Current)
Rendering CPU-based, some GPU rasterization Fully GPU-accelerated
Redraw Overhead Full redraw per frame (clear + redraw all) Update buffers only when geometry changes
Effects CPU-based (e.g., gradients, dashing) GPU-based (shaders handle gradients, tapering)
Thick Lines ctx.lineWidth, slow for complex paths Quads/triangles, GPU-rendered
Performance Scaling Poor with high node/layer counts Excellent, handles thousands of points easily
Animation Laggy due to full redraws Smooth, GPU handles rendering

Notes for Your Documentation

Here’s a summary you can add to your project’s Wiki or notes section to document this evolution:

Pre-WebGL Implementation (2D Canvas)

  • Rendering Method: Used HTML5 Canvas 2D API (CanvasRenderingContext2D).
  • Drawing Process:
    • Drew spirals as continuous paths using ctx.beginPath(), ctx.moveTo(), and ctx.lineTo().
    • Applied effects like gradients (ctx.createLinearGradient) and dashing (ctx.setLineDash).
    • Redrew the entire canvas for every parameter change or animation frame.
  • Performance Issues:
    • Slow due to CPU-based rendering and full redraws per frame.
    • Struggled with high node/layer counts (e.g., 50 nodes, 80 layers in denseMirror preset).
    • Thick lines (ctx.lineWidth) and effects like gradients added significant overhead.
    • Animation (e.g., auto-rotation) caused lag due to constant redrawing.
  • Example Bottleneck:
    • Drawing a spiral with 50 nodes, 80 layers, and 4 mirrored versions required computing and drawing ~16,000 points per frame, leading to noticeable lag on lower-end devices.

Transition to WebGL

  • Why WebGL?: Switched to WebGL to leverage GPU acceleration, improving performance for complex spirals and animations.
  • Key Changes:
    • Used WebGL context (canvas.getContext('webgl')) with vertex and fragment shaders.
    • Represented spiral as a series of vertices in buffers, rendered as triangles (gl.TRIANGLES).
    • Moved effects (gradients, tapering, rounding) to shaders, reducing CPU load.
  • Performance Gains:
    • GPU handles rendering, allowing parallel processing of vertices and pixels.
    • Reduced redraw overhead—buffers are updated only when geometry changes.
    • Scales well with high node/layer counts, enabling smooth rendering of complex spirals.
    • Animations like auto-rotation and audio reactivity are now smooth, even with thousands of points.

Lessons Learned

  • The 2D Canvas API is suitable for simple graphics but struggles with complex, dynamic visualizations like the Kathara Spiral.
  • WebGL’s GPU acceleration is essential for real-time rendering of intricate shapes with effects, especially when supporting features like audio reactivity and high node counts.
  • Future optimizations could include:
    • Using a transformation matrix for rotation instead of recomputing vertices.
    • Batching multiple layers into a single draw call to further reduce overhead.

Additional Considerations

If you still have the pre-WebGL version of your script (e.g., in an older commit in your GitHub repository), you can compare the performance directly:

  • Check out the older commit: git checkout <commit-hash>.
  • Run the 2D Canvas version with a complex preset (e.g., denseMirror) and observe the frame rate (you can use the browser’s performance tools, like Chrome DevTools’ Performance tab).
  • Compare with the current WebGL version to quantify the improvement.

This note should provide a clear record of how your script evolved and why the WebGL transition was necessary. Let me know if you’d like to dive deeper into any aspect of the pre-WebGL implementation or explore further optimizations for the current version!

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingdocumentationImprovements or additions to documentationenhancementNew feature or request

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions