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にコンポーネントを登録します。
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
<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 を入れています。レンダリングには時間がかかることがあるので,画像生成中はローディングメッセージを表示します。エラーが出た場合はそのエラーの内容を表示します。
<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 モジュール名,説明文,そして表示倍率(整数倍スケール係数)です。
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 ファイル)では,以下のように記述します。
<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解説を参照してください。
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形式の文字列を得ます。
const ppmData = wasm[props.wasmFunction]()3. canvasへの表示
PPMヘッダーを先読みして画像の寸法を取得し,imageWidth と imageHeight を更新してから imageGenerated を true にします。こうすることで,Vue が <canvas> を最初から正しいサイズで生成します。その後 nextTick() でDOMの更新を待ってから renderPPMToCanvas() で描画します。
この順序が重要です。imageGenerated = true の後に寸法を更新すると,Vue のリアクティビティが putImageData 後に canvas をリサイズしてしまい,描画内容が消去されます。
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 に書き込みます。
スケーリングを行うため,ピクセルデータは一度 オフスクリーン canvas に putImageData で書き込みます。その後,表示 canvas に drawImage でコピーする際に props.scale 倍に拡大します。imageSmoothingEnabled = false を指定することで,CSS の image-rendering: pixelated と組み合わせて,補間なしの最近傍法で拡大されます。scale のデフォルトは 1 です。