Skip to content
Copied!
published on 2026-04-05

2. PPMRenderer

The PPMRenderer component calls a WebAssembly function and renders the PPM-format data it returns.

VitePress lets you register components site-wide so they can be called from any Markdown file. There are several ways to do this; in this project the theme directory is created inside .vitepress, with a components subdirectory containing the Vue component.

.vitepress
├── config.mjs
└── theme
    ├── components
    │   └── PPMRenderer.vue
    └── index.js

The .vitepress/theme/index.js file declares the path to each component to register. Because PPMRenderer.vue is placed in components as shown above, that path is written here. The component is also added to export default.

js
import DefaultTheme from 'vitepress/theme'
import PPMRenderer from './components/PPMRenderer.vue'

export default {
  extends: DefaultTheme,
  enhanceApp({ app }) {
    app.component('PPMRenderer', PPMRenderer)
  }
}

PPMRenderer calls the function wasm-function from the specified wasm-module. That function is expected to return a PPM-format string. The return value is parsed and rendered onto a <canvas>.

The full source of PPMRenderer is shown below. This system uses Single File Components (SFCs).

PPMRenderer.vue
vue
<template>
  <div class="ppm-renderer">
    <div v-if="!imageGenerated && !error" class="loading">
      Rendering {{ imageName }}...
    </div>

    <div v-else-if="error" class="error">
      {{ error }}
    </div>

    <div v-else class="result">
      <canvas
        ref="canvas"
        :width="imageWidth"
        :height="imageHeight"
        class="render-canvas"
      />
      <div class="info">
        <!--<p><strong>{{ imageWidth }} × {{ imageHeight }} pixels</strong></p>-->
        <p>{{ description }}</p>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, nextTick } from 'vue'
import { withBase } from 'vitepress'

const props = defineProps({
  imageName: { type: String, required: true },
  wasmFunction: { type: String, required: true },
  wasmModule: { type: String, required: true },
  description: { type: String, required: true },
})

const canvas = ref(null)
const imageGenerated = ref(false)
const error = ref(null)
const imageWidth = ref(256)
const imageHeight = ref(256)

onMounted(async () => {
  try {
    // Load WASM module from configurable path.
    // Use fetch + blob URL to bypass Vite dev server's public-dir ES module block.
    const fullPath = withBase(props.wasmModule)
    const response = await fetch(fullPath)
    if (!response.ok) throw new Error(`HTTP ${response.status} fetching ${fullPath}`)
    const jsText = await response.text()
    const blob = new Blob([jsText], { type: 'text/javascript' })
    const blobUrl = URL.createObjectURL(blob)
    let wasm
    try {
      wasm = await import(/* @vite-ignore */ blobUrl)
    } finally {
      URL.revokeObjectURL(blobUrl)
    }
    // import.meta.url resolves to blob: in blob context, so pass WASM binary URL explicitly
    const wasmBinaryUrl = new URL(
      fullPath.replace(/\.js$/, '_bg.wasm'),
      window.location.origin
    )
    await wasm.default(wasmBinaryUrl)

    // Call the specific render function
    const ppmData = wasm[props.wasmFunction]()

    // Parse dimensions from PPM header BEFORE making canvas visible,
    // so Vue creates the canvas at the correct size in one pass.
    const headerLines = ppmData.split('\n')
    const [w, h] = headerLines[1].split(' ').map(Number)
    imageWidth.value = w
    imageHeight.value = h
    imageGenerated.value = true

    // Wait for Vue to render the canvas at the correct dimensions
    await nextTick()

    // Draw to already-correctly-sized canvas
    renderPPMToCanvas(ppmData)

  } catch (err) {
    error.value = `Failed to render ${props.imageName}: ${err.message}`
    console.error('PPM rendering error:', err)
  }
})

function renderPPMToCanvas(ppmData) {
  try {
    if (!canvas.value) throw new Error('Canvas not found')

    const ctx = canvas.value.getContext('2d')
    const lines = ppmData.trim().split('\n')

    // Parse PPM header
    let lineIndex = 0

    // Validate P3 magic number
    const magicNumber = lines[lineIndex++]
    if (magicNumber !== 'P3') {
      throw new Error(`Invalid PPM format: expected 'P3', got '${magicNumber}'`)
    }

    // Parse dimensions and update reactive variables
    const [width, height] = lines[lineIndex++].split(' ').map(Number)

    // Skip max color value
    lineIndex++

    // Create image data buffer
    const imageData = ctx.createImageData(width, height)
    const data = imageData.data

    // Parse pixel data
    let pixelIndex = 0
    for (let i = lineIndex; i < lines.length; i++) {
      const line = lines[i].trim()

      if (!line) continue // Skip empty lines

      const rgb = line.split(/\s+/).map(Number)

      // Validate RGB values
      if (rgb.length === 3 && !rgb.some(isNaN)) {
        const dataIndex = pixelIndex * 4

        // Bounds check
        if (dataIndex + 3 < data.length) {
          data[dataIndex] = rgb[0]     // R
          data[dataIndex + 1] = rgb[1] // G
          data[dataIndex + 2] = rgb[2] // B
          data[dataIndex + 3] = 255    // A (full opacity)
          pixelIndex++
        }
      }
    }

    // Render to canvas
    ctx.putImageData(imageData, 0, 0)

  } catch (err) {
    console.error('Canvas rendering error:', err)
    error.value = `Canvas error: ${err.message}`
  }
}
</script>

<style scoped>
.ppm-renderer {
  margin: 2rem 0;
  padding: 1.5rem;
  border: 1px solid var(--vp-c-border);
  border-radius: 8px;
  background: var(--vp-c-bg-soft);
  text-align: center;
}

.render-canvas {
  border: 1px solid var(--vp-c-border);
  border-radius: 4px;
  background: white;
  image-rendering: pixelated;
  max-width: 100%;
  display: block;
  margin: 0 auto;
}

.info {
  margin-top: 1rem;
  color: var(--vp-c-text-2);
  font-size: 0.9rem;
}

.info p {
  margin: 0.25rem 0;
}

.loading, .error {
  padding: 2rem;
  border-radius: 6px;
}

.loading {
  color: var(--vp-c-text-1);
}

.error {
  background: var(--vp-c-danger-soft);
  color: var(--vp-c-danger);
  border: 1px solid var(--vp-c-danger);
}
</style>

Each section is explained below.

Template

First, the Vue template. The component's data is bound to this virtual DOM and inserted into VitePress's DOM.

The div.ppm-renderer block contains a canvas for displaying the image and an info section for supplementary text. Because rendering can take time, a loading message is displayed while the image is being generated. If an error occurs, its message is shown instead.

vue
<template>
  <div class="ppm-renderer">
    <div v-if="!imageGenerated && !error" class="loading">
      Rendering {{ imageName }}...
    </div>

    <div v-else-if="error" class="error">
      {{ error }}
    </div>

    <div v-else class="result">
      <canvas
        ref="canvas"
        :width="imageWidth"
        :height="imageHeight"
        class="render-canvas"
      />
      <div class="info">
        <p>{{ description }}</p>
      </div>
    </div>
  </div>
</template>

Props

The template accepts four pieces of information: the image name, the WASM function name, the WASM module path, and a description string.

js
const props = defineProps({
  imageName: { type: String, required: true },
  wasmFunction: { type: String, required: true },
  wasmModule: { type: String, required: true },
  description: { type: String, required: true },
})

The calling side (a Markdown file) writes it as follows:

html
<ClientOnly>
  <PPMRenderer
    image-name="Gradient"
    wasm-function="render_gradient"
    wasm-module="/wasm/raytracing_demos/raytracing_demos.js"
    description="Red-to-green gradient"
  />
</ClientOnly>

Mounted Callback

This is the code called when the component is mounted. It consists of three main steps.

1. Loading the WASM Module

Load the WASM glue code (.js) and initialize the WASM binary. withBase() prepends the configured base path from .vitepress/config.* when set.

When WASM files are placed in the public directory, the Vite dev server's restrictions prevent loading them with a normal import(). This is worked around by fetching the JS text with fetch() and importing it via a Blob URL. For the full background and the general-purpose pattern, see the WebAssembly explanation in Daydream Notes.

js
const fullPath = withBase(props.wasmModule)
const response = await fetch(fullPath)
const jsText = await response.text()
const blob = new Blob([jsText], { type: 'text/javascript' })
const blobUrl = URL.createObjectURL(blob)
let wasm
try {
  wasm = await import(/* @vite-ignore */ blobUrl)
} finally {
  URL.revokeObjectURL(blobUrl)
}
const wasmBinaryUrl = new URL(
  fullPath.replace(/\.js$/, '_bg.wasm'),
  window.location.origin
)
await wasm.default(wasmBinaryUrl)

2. Calling the WASM Function

Call the module's function using the function name specified in props (wasmFunction) to obtain a PPM-format string.

js
const ppmData = wasm[props.wasmFunction]()

3. Displaying on Canvas

Read the image dimensions from the PPM header first, update imageWidth and imageHeight, then set imageGenerated to true. This way, Vue creates the <canvas> at the correct dimensions from the start. Afterwards, wait for the DOM update with nextTick() before calling renderPPMToCanvas() to draw.

The order matters. If dimensions are updated after imageGenerated = true, Vue's reactivity will resize the canvas after putImageData, erasing the drawn content.

js
const headerLines = ppmData.split('\n')
const [w, h] = headerLines[1].split(' ').map(Number)
imageWidth.value = w
imageHeight.value = h
imageGenerated.value = true
await nextTick()
renderPPMToCanvas(ppmData)

Rendering

The supported PPM format starts with P3, so the function first checks that the return value begins with P3. The next line contains the width and height, which are used to size the <canvas>. The maximum color value is assumed to be 255. The pixel brightness values that follow are recorded in R, G, B order and are written into the canvas pixel data.