LoginSignup
42
32

More than 3 years have passed since last update.

WebGLでSound Shaderの実装

Posted at

ShadertoyではGLSLを書くと音を鳴らすことができます。通常はグラフィックのために使用するGLSLでどのようにサウンドを生成しているのか疑問だったので、サウンド生成部分を実装してみました。

サウンドはリアルタイムに生成しているわけではなく、以下のようにあらかじめ指定した時間間隔のサウンドをすべて計算してオーディオバッファーに書き込んでおくようです。

  1. シェーダーを使ってある時刻の音の波形をGPUで計算する
  2. GPUで計算した値をCPU側で読み出してオーディオバッファーに書き込む

波形の計算をフラグメントシェーダーで行う方法と頂点シェーダーで行う方法があり、今回はそれぞれ両方を試してみました。

フラグメントシェーダーを用いた実装

フラグメントシェーダーでの実装は以下の記事を参考にしています。
ShadertoyのSound ShaderをThree.jsで実装してみた - マルシテイア

Three.jsか生のWebGLかの違いはありますが、基本的にやっていることは同じなので解説はソースコード中に書くだけにしておきます。
動くサンプルを以下において置きました。「start」ボタン押すとメトロノームのような音が鳴ります。
https://aadebdeb.github.io/Sample_WebGL_SoundShader/fragment.html

フラグメントシェーダー
#version 300 es

precision highp float;

out vec4 o_color;

uniform float u_sampleRate;
uniform float u_blockOffset;
uniform vec2 u_resolution;

#define BPM 120.0

float timeToBeat(float time) {
  return time / 60.0 * BPM;
}

float sine(float freq, float time) {
  return sin(freq * 6.28318530718 * time);
}

// Shadertoyと同じmainSound関数
vec2 mainSound(float time) {
  float beat = timeToBeat(time);
  float freq = mod(beat, 4.0) >= 1.0 ? 440.0 : 880.0;
  float amp = exp(-6.0 * fract(beat));
  return vec2(sine(freq, time) * amp);
}

void main(void) {
  vec2 coord = floor(gl_FragCoord.xy);
  float time = u_blockOffset + (coord.x + coord.y * u_resolution.x) / u_sampleRate;
  vec2 sound = clamp(mainSound(time), -1.0, 1.0);
  // 16ビット精度になるように左右それぞれ一つに対してvec4のうちの2つの要素を使う
  vec2 v = floor((0.5 + 0.5 * sound) * 65536.0);
  vec2 vl = mod(v, 256.0) / 255.0;
  vec2 vh = floor(v / 256.0) / 255.0;
  o_color = vec4(vl.x, vh.x, vl.y, vh.y);
}
function createAudio() {
  // 180秒の音を計算する
  const DURATION = 180; // seconds
  const WIDTH = 512;
  const HEIGHT = 512;

  const audioCtx = new AudioContext();
  // オーディオの値を書き込むバッファーを作成する
  const audioBuffer = audioCtx.createBuffer(2, audioCtx.sampleRate * DURATION, audioCtx.sampleRate);

  // 音の波形計算に利用するcanvasを作成する
  const canvas = document.createElement('canvas');
  canvas.width = WIDTH;
  canvas.height = HEIGHT;
  const gl = canvas.getContext('webgl2');

  const program = createProgramFromSource(gl, VERTEX_SHADER, FRAGMENT_SHADER);
  const uniforms = getUniformLocations(gl, program, ['u_sampleRate', 'u_blockOffset', 'u_resolution']);

  const samples = WIDTH * HEIGHT;
  const numBlocks = Math.ceil((audioCtx.sampleRate * DURATION) / samples);
  const pixels = new Uint8Array(WIDTH * HEIGHT * 4);
  const outputL = audioBuffer.getChannelData(0);
  const outputR = audioBuffer.getChannelData(1);

  gl.useProgram(program);
  gl.uniform1f(uniforms['u_sampleRate'], audioCtx.sampleRate);
  gl.uniform2f(uniforms['u_resolution'], WIDTH, HEIGHT);
  // 時間で区切って波形の計算とオーディオバッファーへの書き込みを行う
  for (let i = 0; i < numBlocks; i++) {
    gl.uniform1f(uniforms['u_blockOffset'], i * samples / audioCtx.sampleRate);
    // レンダーバッファーに波形を書き込む
    gl.drawArrays(gl.TRIANGLES, 0, 6);
    // canvasに書き込んだ値をpixelsに読み込む
    gl.readPixels(0, 0, WIDTH, HEIGHT, gl.RGBA, gl.UNSIGNED_BYTE, pixels);

    for (let j = 0; j < samples; j++) {
      // 2つに分けて保存した値から16ビットの値を復元してオーディオバッファーに書き込む
      outputL[i * samples + j] = (pixels[j * 4 + 0] + 256 * pixels[j * 4 + 1]) / 65535 * 2 - 1;
      outputR[i * samples + j] = (pixels[j * 4 + 2] + 256 * pixels[j * 4 + 3]) / 65535 * 2 - 1;
    }
  }

  const node = audioCtx.createBufferSource();
  node.connect(audioCtx.destination);
  node.buffer = audioBuffer;
  node.loop = false;
  return node;
}

const node = createAudio();
// 音の再生
node.start(0);

浮動小数点数テクスチャを使用する

先に紹介した方法ではcanvasに直接書き込むと8ビットになるため、シェーダー側で左右それぞれの値を分割して保存し、JS側で復元することで16ビット精度でオーディオバッファーに書き込んでいました。

AudioBufferの解説によると32ビット精度で書き込まれるようなので精度が足りておらず、また値を分割してまた復元するというのはめんどくさい感じがします。

バッファには次の形式でデータが書き込まれます: ノンインターリーブ IEEE754 32bit リニア PCMで、-1から+1の範囲で正規化されています。つまり、32bit 浮動小数点バッファで、それぞれのサンプルは-1.0から1.0の間です。
AudioBuffer - Web API | MDN

WebGL2では拡張機能を利用することで浮動小数点テクスチャを利用できるようになります。浮動小数点数テクスチャに書き込んだものをreadPixelsすれば32ビット精度の値をそのままオーディオバッファーに書き込めそうなので試してみました。

サンプルは以下から動作を確認できます。
https://aadebdeb.github.io/Sample_WebGL_SoundShader/fragment-float.html

フラグメントシェーダー
...
out vec2 o_sound;
...
void main(void) {
  vec2 coord = floor(gl_FragCoord.xy);
  float time = u_blockOffset + (coord.x + coord.y * u_resolution.x) / u_sampleRate;
  // 先に紹介した方法と異なりmainSoundの返り値をそのまま書き込んでいる
  o_sound = mainSound(time);
}
...
const gl = canvas.getContext('webgl2');
// 拡張機能を有効にする
gl.getExtension('EXT_color_buffer_float');
...
// フレームバッファーを作成して浮動小数点数テクスチャをバインドする
const framebuffer = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RG32F, WIDTH, HEIGHT, 0, gl.RG, gl.FLOAT, null);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
...
// 32ビット精度の値を保持できるようにFloat32Arrayで読み込み用の配列を作成しておく
const pixels = new Float32Array(WIDTH * HEIGHT * 2);
...
for (let i = 0; i < numBlocks; i++) {
  ...
  // gl.FLOATでreadPixelsする
  gl.readPixels(0, 0, WIDTH, HEIGHT, gl.RG, gl.FLOAT, pixels);
  ...
  for (let j = 0; j < samples; j++) {
    outputDataL[i * samples + j] = pixels[j * 2];
    outputDataR[i * samples + j] = pixels[j * 2 + 1];
  }
}

頂点シェーダーを用いた実装

頂点シェーダーで波形の計算をする場合、Transform Feedbackを用います。波形の値をTransform FeedbackでVBOに書き出し、getBufferSubDataで配列に読み込むことでGPUで計算したものをCPU側で利用できるようになります。

サンプルは以下に置いておきました。
https://aadebdeb.github.io/Sample_WebGL_SoundShader/vertex.html

頂点シェーダー
#version 300 es

out vec2 o_sound;

uniform float u_blockOffset;
uniform float u_sampleRate;

#define BPM 120.0

float timeToBeat(float time) {
  return time / 60.0 * BPM;
}

float sine(float freq, float time) {
  return sin(freq * 6.28318530718 * time);
}

vec2 mainSound(float time) {
  float beat = timeToBeat(time);
  float freq = mod(beat, 4.0) >= 1.0 ? 440.0 : 880.0;
  float amp = exp(-6.0 * fract(beat));
  return vec2(sine(freq, time) * amp);
}

void main(void) {
  float time = u_blockOffset + float(gl_VertexID) / u_sampleRate;
  o_sound = mainSound(time);
}
...
const array = new Float32Array(2 * SAMPLES);
const vbo = createVbo(gl, array, gl.DYNAMIC_COPY);
const transformFeedback = gl.createTransformFeedback();
...
gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, transformFeedback);
gl.enable(gl.RASTERIZER_DISCARD);
gl.useProgram(program);
gl.uniform1f(uniforms['u_sampleRate'], audioCtx.sampleRate);
for (let i = 0; i < numBlocks; i++) {
  gl.uniform1f(uniforms['u_blockOffset'], i * SAMPLES / audioCtx.sampleRate);
  // Transoform FeedbackでVBOに値を書き込む
  gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 0, vbo);
  gl.beginTransformFeedback(gl.POINTS);
  gl.drawArrays(gl.POINTS, 0, SAMPLES);
  gl.endTransformFeedback();
  // VBOからarrayに値を書き出す
  gl.getBufferSubData(gl.TRANSFORM_FEEDBACK_BUFFER, 0, array);

  for (let j = 0; j < SAMPLES; j++) {
    outputL[i * SAMPLES + j] = array[j * 2];
    outputR[i * SAMPLES + j] = array[j * 2 + 1];
  }
}

終わりに

WebGLでSound Shaderを実装してみました。GLSLでサウンドというと黒魔術的ですが、実装してみると意外と単純な感じがしました。Shadertoyで試しているときはどれぐらいの負荷なら大丈夫かわからず不安でしたが、非リアルタイムに計算しているので重いシェーダーを書いても問題なさそうですね。

42
32
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
42
32