Feylo

WebGLで画像や動画を描画するテクスチャクラスを作成する

TAG:

今回は、Three.jsでいうTextureに相当する、画像や動画を描画するテクスチャクラスをWebGLで作成する方法を解説します。デモサイトでは、平面に画像を、立方体に動画を貼り付けて表示しています。

この記事でわかること

  • WebGLで画像や動画を描画する方法

はじめに

前回は、WebGLで法線の可視化や座標確認のためのヘルパー関数を作成する方法について解説しました。

今回は、画像や動画を描画するテクスチャクラスを作成していきます。

コードはGitHubで公開しているので参考にしてみてください!

GitHub - nono-k/webgl-study-note
Contribute to nono-k/webgl-study-note development by creating an account on GitHub.
GitHub - nono-k/webgl-study-note favicon
GitHub - nono-k/webgl-study-note favicon
github.com
GitHub - nono-k/webgl-study-note
GitHub - nono-k/webgl-study-note

実装の考え方

WebGLで画像や動画を表示するときには、**テクスチャ(Texture)**という仕組みを使います。Three.jsなどのライブラリを使っていると、画像を読み込んでマテリアルを設定するだけで簡単に表示できます。

しかし、その内部では、

  • 画像を読み込む
  • WebGLのテクスチャを作成する
  • テクスチャのパラメータを設定する
  • 画像データをGPUへ転送する
  • シェーダーからテクスチャを参照する

といった処理が行われています。

今回はWebGL2.0を使って、まずは画像をテクスチャとして表示する方法を確認し、その後に動画をテクスチャとして扱う方法まで解説します。

WebGLにおけるテクスチャとは

WebGLでは、画像そのものを直接シェーダーから参照することはできません。まず画像データをGPU側のメモリに転送し、WebGLが扱えるテクスチャオブジェクトにする必要があります。

シェーダー側では、uniform変数としてテクスチャをsampler2Dとして受け取り、次のように使用します。

fragment.glsl
uniform sampler2D uTexture;

in vec2 vUv;
out vec4 fragColor;
void main() {
  fragColor = texture(uTexture, vUv);
}

それでは、実際にWebGLで画像をテクスチャとして表示する方法を見ていきましょう。

Textureクラスの作成

coreディレクトリにTexture.tsファイルを作成していきます。index.tsファイルも更新して、Textureクラスをエクスポートするようにしておきましょう。

index.ts
// Core
export { Texture } from './core/Texture';

まずは、テクスチャのソースとして扱える型とオプションの型を定義します。

Texture.ts
export type TextureSource = 
  |string 
  | HTMLImageElement 
  | HTMLVideoElement 
  | HTMLCanvasElement 
  | OffscreenCanvas 
  | ImageBitmap 
  | ImageData;

export type TextureOptions = {
  wrapS?: number;
  wrapT?: number;
  minFilter?: number;
  magFilter?: number;
  generateMipmaps?: boolean;
  flipY?: boolean;
  premultiplyAlpha?: boolean;
  format?: number;
  internalFormat?: number;
  type?: number;
  width?: number;
  height?: number;
}

TextureSourceにある通り、テクスチャのソースとしては文字列(URL)や画像、動画、キャンバスなど様々な型を扱うことができます。

TextureOptionsは、WebGLのテクスチャを作成するときに設定できるオプションです。項目は次のようになります。

プロパティ説明デフォルト値
wrapSテクスチャの水平方向のラップモードgl.CLAMP_TO_EDGE
wrapTテクスチャの垂直方向のラップモードgl.CLAMP_TO_EDGE
minFilterテクスチャの縮小フィルタgl.LINEAR
magFilterテクスチャの拡大フィルタgl.LINEAR
generateMipmapsミップマップを生成するかどうかtrue
flipYY軸方向に反転するかどうかtrue
premultiplyAlphaアルファを乗算するかどうかfalse
formatテクスチャのフォーマットgl.RGBA
internalFormatテクスチャの内部フォーマットgl.RGBA
typeテクスチャのデータ型gl.UNSIGNED_BYTE
widthテクスチャの幅(ピクセル)0
heightテクスチャの高さ(ピクセル)0

デフォルト値は、オプションに値が指定されなかった場合に使用される値になります。これはコンストラクタ内で??演算子を使って設定されています。

Texture.ts
export class Texture {
  // ...
  constructor(gl: WebGL2RenderingContext, source: TextureSource, options: TextureOptions = {}) {
    this.gl = gl;
    const isVideo = source instanceof HTMLVideoElement;
    this.wrapS = options.wrapS ?? gl.CLAMP_TO_EDGE;
    this.wrapT = options.wrapT ?? gl.CLAMP_TO_EDGE;
    this.minFilter = options.minFilter ?? gl.LINEAR;
    this.magFilter = options.magFilter ?? gl.LINEAR;
    this.generateMipmaps = options.generateMipmaps ?? !isVideo;
    this.flipY = options.flipY ?? true;
    this.premultiplyAlpha = options.premultiplyAlpha ?? false;
    this.format = options.format ?? gl.RGBA;
    this.internalFormat = options.internalFormat ?? gl.RGBA;
    this.type = options.type ?? gl.UNSIGNED_BYTE;
    this.width = options.width ?? 0;
    this.height = options.height ?? 0;
  }
}

コンストラクタでテクスチャを作成する

Textureクラスのコンストラクタでは、gl.createTexture()を使ってWebGLのテクスチャオブジェクトを作成し、gl.bindTexture(gl.TEXTURE_2D, tex)でバインドする処理を行います。

Texture.ts
export class Texture {
  // ...
  constructor(gl: WebGL2RenderingContext, source: TextureSource, options: TextureOptions = {}) {
    // ...    
    const tex = gl.createTexture();
    this.texture = tex;

    gl.bindTexture(gl.TEXTURE_2D, tex);
  }
}

その後に、gl.texParameteriを使って各種設定を行います。

MIN_FILTERとMAG_FILTER

MIN_FILTERはテクスチャを縮小して表示するときのフィルタリング方法を指定します。MAG_FILTERはテクスチャを拡大して表示するときのフィルタリング方法を指定します。

Texture.ts
export class Texture {
  // ...
  constructor(gl: WebGL2RenderingContext, source: TextureSource, options: TextureOptions = {}) {
    // ...    
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, this.minFilter);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, this.magFilter);
  }
}

デフォルト値では、どちらもgl.LINEARが使用されます。これは、周囲のピクセルを補間して滑らかに表示するフィルタリング方法です。

他にgl.NEARESTというフィルタリング方法もあります。これは、最も近いピクセルの値をそのまま使用する方法で、ドット絵のような表現に適しています。

WRAP_SとWRAP_T

WRAP_Sはテクスチャの水平方向のラップ方法を指定し、WRAP_Tはテクスチャの垂直方向のラップ方法を指定します。

Texture.ts
export class Texture {
  // ...
  constructor(gl: WebGL2RenderingContext, source: TextureSource, options: TextureOptions = {}) {
    // ...

    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, this.wrapS);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, this.wrapT);
  }
}

デフォルト値では、どちらもgl.CLAMP_TO_EDGEが使用されます。これは、テクスチャの端のピクセルを繰り返し使用してラップする方法です。

他にgl.REPEATというラップ方法もあります。これは、テクスチャを繰り返して表示する方法で、パターン状のテクスチャに適しています。また、gl.MIRRORED_REPEATというラップ方法もあり、テクスチャを反転させながら繰り返して表示する方法です。

URLから画像を読み込む

sourceが文字列だった場合は、その文字列をURLとして画像を読み込みます。

Texture.ts
if (typeof source === 'string') {
  this.source = source;
  this.load(source);
  return;
}

例えば、次のようにした場合です。

const texture = new Texture(gl, '/images/image.png');

画像読み込みの処理は次のようになります。

Texture.ts
private loadImage(src: string): Promise<HTMLImageElement> {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.crossOrigin = 'anonymous';
    img.src = src;
    img.onload = () => resolve(img);
    img.onerror = e => reject(e);
  });
}

ここではPromiseを使って非同期に画像を読み込んでいるので、次のようにawaitを使って、画像の読み込み完了を待つことができます。

Texture.ts
this.source = await this.loadImage(source);

なのでloadメソッドは次の通りです。

Texture.ts
async load(source: TextureSource) {
  if (typeof source === 'string') {
    this.source = await this.loadImage(source);
  } else {
    this.source = source;
  }

  this.needsUpdate = true;
  this.update();
}

読み込みが完了すると、needsUpdatetrueに設定し、updateメソッドを呼び出してGPUへ転送します。それでは、updateメソッドの内容を見てみましょう。

update()で画像をGPUへ送る

updateメソッドは、次のようになります。

Texture.ts
update(uint = 0) {
  this.bind(uint);

  const src = this.source;
  if (!src || typeof src === 'string') return;

  // ...
}

まず、bindメソッドを呼び出します。bindメソッドは次のようにしてます。

Texture.ts
bind(uint = 0) {
  const gl = this.gl;
  gl.activeTexture(gl.TEXTURE0 + uint);
  gl.bindTexture(gl.TEXTURE_2D, this.texture);
}

これは、テクスチャユニットを指定して、そのテクスチャをバインドする処理です。たとえば、texture.bind(0)ならTEXTURE0を使用します。

flipY

続いて、flipYの設定について説明します。flipYは、テクスチャの垂直方向を反転させるかどうかを指定するオプションです。WebGLでは、画像の座標系とテクスチャの座標系が異なるため、通常はflipYtrueに設定して反転させます。

Texture.ts
update(uint = 0) {
  // ...
  const gl = this.gl;
  gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, this.flipY);
}

premultiplyAlpha

premultiplyAlphaは、テクスチャのアルファ値を事前に乗算するかどうかを指定するオプションです。デフォルトでfalseにしているので、今回の実装では無効になっています。

Texture.ts
update(uint = 0) {
  // ...
  gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, this.premultiplyAlpha);
}

画像サイズを取得する

画像のサイズは次のように取得しています。

Texture.ts
const { width, height } = this.getSize(src);

getSizeメソッドは次のように実装されています。

Texture.ts
private getSize(src: Exclude<TextureSource, string>) {
  if (src instanceof HTMLVideoElement) {
    return { width: src.videoWidth, height: src.videoHeight };
  }

  return { width: src.width, height: src.height };
}

画像と動画では、サイズの取得方法が異なるため、getSizeメソッドでそれぞれに対応しています。このwidthheightを使って、2種類のAPIを使い分けます。

texImage2DとtexSubImage2D

画像をGPUへ転送するとき、コードでは2種類のAPIを使い分けています。

Texture.ts
if (width !== this.width || height !== this.height) {
  this.width = width;
  this.height = height;
  gl.texImage2D(gl.TEXTURE_2D, 0, this.internalFormat, this.format, this.type, src);
} else {
  gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, this.format, this.type, src);
}

texImage2Dは、テクスチャの画像データを新しく設定するときに使用します。この判定は、画像のサイズが前回と異なる場合に行われます。

一方、texSubImage2Dは、既存のテクスチャの一部を更新するときに使用します。サイズが変わらない場合は、texSubImage2Dを使うことで効率的にGPUのメモリを更新できます。特に動画テクスチャの更新では、この方法が有効になります。

画像ではMipmapを生成する

画像テクスチャの場合、通常はMipmapを生成して、異なる解像度での描画を効率化します。Mipmapは、テクスチャの縮小版を自動的に生成する仕組みです。

Texture.ts
if (this.generateMipmaps && !isVideo) {
  gl.generateMipmap(gl.TEXTURE_2D);
}

動画の場合は、毎フレーム画像が変化するので、Mipmapを生成するとパフォーマンスに影響を与える可能性があります。そのため、動画テクスチャでは通常Mipmapを生成しません。

これでTextureクラスは一通り説明しました。全コードはリポジトリを参照してください。

Program.tsへ追加

シェーダーのユニフォームにテクスチャを設定できるように、ProgramクラスのsetUniformsメソッドを次のように追加しましょう。

Program.ts
export class Program {
  // ...
  setUniforms() {
    // ...
    if (uniform.value.texture) {
      textureUint = textureUint + 1;
      uniform.value.update(textureUint);
      return this.setUniform(gl, location, textureUint, 'init');
    }
  }
}

画像と動画を描画する準備はこれで整いました。次に、実際に描画するコードを見ていきましょう。

画像と動画を描画する

画像と動画の描画
画像と動画の描画

デモをみる

デモの全コードは次のようになります。

画像と動画の描画方法
render.ts
import image from '@/assets/images/autumn.jpg';
import video from '@/assets/video/video-01.mp4';
import { Box, Camera, Mesh, Orbit, Plane, Program, Render, Scene, Texture } from '@/lib/webgl';
import fragment from './index.frag?raw';
import vertex from './index.vert?raw';

export const onload = () => {
  const canvas = document.getElementById('webgl-canvas') as HTMLCanvasElement;
  const render = new Render(canvas);
  render.fitScreen();
  const gl = render.gl;
  gl.clearColor(1.0, 1.0, 1.0, 1.0);
  gl.enable(gl.DEPTH_TEST);

  const camera = new Camera(gl, { fov: 45, near: 0.1, far: 100 });
  camera.position.set(1, 1, 4);
  camera.lookAt([0, 0, 0]);

  const controls = new Orbit(camera);

  const scene = new Scene();

  const planeGeometry = new Plane(gl, { width: 3, height: 2 });
  const imageTexture = new Texture(gl, image.src);

  const program = new Program(gl, {
    vertex,
    fragment,
    uniforms: {
      uTexture: { value: imageTexture },
    },
  });

  const plane = new Mesh(gl, { geometry: planeGeometry, program });
  plane.position.set(-2, 0, 0);
  scene.add(plane);

  const boxGeometry = new Box(gl, { width: 1.5, height: 1.5, depth: 1.5 });

  const videoElement = document.createElement('video') as HTMLVideoElement;
  videoElement.src = video;
  videoElement.loop = true;
  videoElement.muted = true;
  videoElement.setAttribute('playsinline', 'playsinline');
  videoElement.play();

  const videoTexture = new Texture(gl, videoElement);

  const videoProgram = new Program(gl, {
    vertex,
    fragment,
    uniforms: {
      uTexture: { value: videoTexture },
    },
  });

  const box = new Mesh(gl, { geometry: boxGeometry, program: videoProgram });
  box.rotation.set(-Math.PI / 4, Math.PI / 4, 0);
  box.position.set(1.5, 0, -2);
  scene.add(box);

  const update = () => {
    box.rotation.y += 0.005;
    box.rotation.x += 0.005;

    render.render({ scene, camera });

    controls.update();

    requestAnimationFrame(update);
  };

  update();

  const resize = () => {
    render.setSize(window.innerWidth, window.innerHeight);
    camera.perspective({ aspect: gl.canvas.width / gl.canvas.height });
  };
  window.addEventListener('resize', resize);
  resize();
};

デモでは、平面(Plane)に画像を、立方体(Box)に動画をテクスチャとして貼り付けて描画しています。

平面(Plane)に画像を貼り付けるコードは次のようになります。

render.ts
const planeGeometry = new Plane(gl, { width: 3, height: 2 });
const imageTexture = new Texture(gl, image.src);

const program = new Program(gl, {
  vertex,
  fragment,
  uniforms: {
    uTexture: { value: imageTexture },
  },
});

const plane = new Mesh(gl, { geometry: planeGeometry, program });
scene.add(plane);

画像読み込みの処理は、解説したとおりTextureの内部で行っているので、画像のパスを指定するだけでテクスチャとして利用できます。

立方体(Box)に動画を貼り付けるコードは次のようになります。

render.ts
const boxGeometry = new Box(gl, { width: 1.5, height: 1.5, depth: 1.5 });

const videoElement = document.createElement('video') as HTMLVideoElement;
videoElement.src = video;
videoElement.loop = true;
videoElement.muted = true;
videoElement.setAttribute('playsinline', 'playsinline');
videoElement.play();

const videoTexture = new Texture(gl, videoElement);

const videoProgram = new Program(gl, {
  vertex,
  fragment,
  uniforms: {
    uTexture: { value: videoTexture },
  },
});

const box = new Mesh(gl, { geometry: boxGeometry, program: videoProgram });
scene.add(box);

動画の場合は、HTMLVideoElementを作成して再生し、その要素をTextureに渡すことでテクスチャとして利用できます。

ぜひデモサイトでも確認してみてください!

まとめ

今回は、WebGLでの画像と動画を描画するTextureクラスを作成しました。これにより、画像や動画を簡単にテクスチャとして利用できるようになりました。

次回は、このTextureクラスを使用してキューブマップの作成方法を解説したいと思います。

Share

Random

ランダムな記事