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

2. PPMRenderer

WebAssembly関数を呼び,結果として返されるPPM形式のデータをレンダリングするコンポーネント PPMRenderer を作成しました。

VitePressはシステムワイドで利用できるようにコンポーネントを登録すると,各Markdownファイルから呼び出すことができるようになります。この登録はいくつか方法があるようですが,本稿では以下のように theme ディレクトリを .vitepress 配下に作成し,その中に components ディレクトリを作成し,Vueコンポーネントを配置します。

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

.vitepress/theme/index.js ファイルには,登録したいコンポーネントのパスを書きます。PPMRenderer.vueは上記の通りcomponentsディレクトリに配置していますから,このパスを記述します。また,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 は,指定した wasm-module の関数 wasm-function を呼びます。この関数はPPM形式のStringを返すことが期待されています。返り値を元に,canvas に画像データをレンダリングします。

PPMRenderer のソースコードを以下に示します。このシステムでは単一ファイルコンポーネント(Single File Component, SFC)を使用します。

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 * scale"
        :height="imageHeight * scale"
        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 },
  scale: {
    type: Number,
    default: 1,
    validator: (v) => Number.isInteger(v) && v >= 1,
  },
})

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 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
    const [width, height] = lines[lineIndex++].split(' ').map(Number)

    // Skip max color value
    lineIndex++

    // Draw pixel data to offscreen canvas at native size
    const offscreen = document.createElement('canvas')
    offscreen.width = width
    offscreen.height = height
    const offCtx = offscreen.getContext('2d')
    const imageData = offCtx.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++
        }
      }
    }

    offCtx.putImageData(imageData, 0, 0)

    // Scale up to display canvas using nearest-neighbor interpolation
    const ctx = canvas.value.getContext('2d')
    ctx.imageSmoothingEnabled = false
    ctx.drawImage(offscreen, 0, 0, width * props.scale, height * props.scale)

  } 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>

一部分ずつ解説していきます。

テンプレート

まず,Vue テンプレートです。コンポーネントのデータが,この仮想 DOM にバインドされ,VitePress の DOM に追加されます。

div.ppm-renderer ブロックに画像を表示する canvas と,補助的な情報を表示する info を入れています。レンダリングには時間がかかることがあるので,画像生成中はローディングメッセージを表示します。エラーが出た場合はそのエラーの内容を表示します。

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 * scale"
        :height="imageHeight * scale"
        class="render-canvas"
      />
      <div class="info">
        <p>{{ description }}</p>
      </div>
    </div>
  </div>
</template>

props

このテンプレートが受け取る情報は 5 つです。この画像の名前,WASM 関数名,WASM モジュール名,説明文,そして表示倍率(整数倍スケール係数)です。

js
const props = defineProps({
  imageName: { type: String, required: true },
  wasmFunction: { type: String, required: true },
  wasmModule: { type: String, required: true },
  description: { type: String, required: true },
  scale: {
    type: Number,
    default: 1,
    validator: (v) => Number.isInteger(v) && v >= 1,
  },
})

テンプレートの呼び出し側(Markdown ファイル)では,以下のように記述します。

html
<ClientOnly>
  <PPMRenderer
    image-name="Gradient"
    wasm-function="render_gradient"
    wasm-module="/wasm/raytracing_demos/raytracing_demos.js"
    description="赤から緑へのグラデーション"
  />
</ClientOnly>

マウント後コールバック

このコンポーネントがマウントされたときに呼ばれるコードです。大きく3つのステップで構成されています。

1. WASMモジュールの読み込み

WASMのグルーコード(.js)を読み込み,WASMバイナリを初期化します。withBase()は,.vitepress/config.*baseを設定している場合にパスの先頭へ付与します。

WASMファイルをpublicディレクトリに置いた場合,Vite開発サーバーの制約により通常のimport()では読み込めません。fetch()でJSテキストを取得しBlob URL経由でインポートする方法で回避しています。詳細な背景と汎用パターンはdaydream側のWebAssembly解説を参照してください。

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. WASM関数の呼び出し

propsで指定された関数名(wasmFunction)でモジュールの関数を呼び,PPM形式の文字列を得ます。

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

3. canvasへの表示

PPMヘッダーを先読みして画像の寸法を取得し,imageWidthimageHeight を更新してから imageGeneratedtrue にします。こうすることで,Vue が <canvas> を最初から正しいサイズで生成します。その後 nextTick() でDOMの更新を待ってから renderPPMToCanvas() で描画します。

この順序が重要です。imageGenerated = true の後に寸法を更新すると,Vue のリアクティビティが putImageData 後に canvas をリサイズしてしまい,描画内容が消去されます。

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)

レンダリング

対応する PPM 形式では,データの先頭が P3 で始まるため,返り値の先頭が P3 かどうかを確認します。色の最大値は 255 と仮定します。そのあとにはピクセルの輝度値が R, G, B の順で記録されているので,ピクセルデータを ImageData に書き込みます。

スケーリングを行うため,ピクセルデータは一度 オフスクリーン canvasputImageData で書き込みます。その後,表示 canvas に drawImage でコピーする際に props.scale 倍に拡大します。imageSmoothingEnabled = false を指定することで,CSS の image-rendering: pixelated と組み合わせて,補間なしの最近傍法で拡大されます。scale のデフォルトは 1 です。