はじめに
p5.jsにはnoiseという関数があります。これをGLSL内部で使いたくなったので、実装したことがありました。だいぶ前のことです(1年半前)。用意するのは64x64のランダムフロート値で、これをシェーダーで扱って作って遊んでました(自作ライブラリで)。しかしp5で使うのはfloatTextureの取り扱いに難があり諦めていました。ですが、floatをRGBA形式にして送り込んで向こうでfloatに復元すれば割と簡単に実装できることに気づいたので、記事としてまとめておきます。
コード全文
一応、ノイズの定番としてテラインをやっています。ライティングがへたくそなので大した見栄えではないですが、参考までに...(位置ずらしに関しては一応成功しています)。
p5 noise GLSL
/*
setUniformの仕様で2回目以降はスルーされるのでそれかもしれない
...
64x64でいいそうです
https://openprocessing.org/sketch/1886531
これ使えばいいですね。あとは色を小数に変換するだけ。らくちん。のはず。
*/
let sh;
let randomTex;
function setup() {
createCanvas(600, 600, WEBGL);
pixelDensity(1);
randomTex = createRandomTableArray(64, 64);
sh = baseMaterialShader().modify({
vertexDeclarations:`
uniform float uTime;
uniform sampler2D uRandom;
float noiseValue;
vec4 noiseVector;
float dt = 1.0/100.0;
OUT float vNoise;
// color -> float
float convertToFloat(vec4 c){
uint r = uint(round(c.r*255.0));
uint g = uint(round(c.g*255.0));
uint b = uint(round(c.b*255.0));
uint a = uint(round(c.a*255.0));
return uintBitsToFloat(r|(g<<8)|(b<<16)|(a<<24));
}
// Perlinの取得
float getPerlin(int n){
n = n & 4095;
float i = float(n);
float x = mod(i, 64.0) + 0.5;
float y = floor(i / 64.0) + 0.5;
return convertToFloat(texture(uRandom, vec2(x, y) / 64.0));
}
float scaled_cosine(float x){ return 0.5 * (1.0 - cos(x * 3.14159)); }
float p5noise(float x, float y, float z){
x = abs(x);
y = abs(y);
z = abs(z);
int xi = int(floor(x));
int yi = int(floor(y));
int zi = int(floor(z));
float xf = x - floor(x);
float yf = y - floor(y);
float zf = z - floor(z);
float rxf;
float ryf;
float r = 0.0;
float ampl = 0.5;
float n1 = 0.0;
float n2 = 0.0;
float n3 = 0.0;
for(int octave = 0; octave < 4; octave++){
int of = xi + (yi << 4) + (zi << 8);
rxf = scaled_cosine(xf);
ryf = scaled_cosine(yf);
n1 = getPerlin(of);
n1 += rxf * (getPerlin(of + 1) - n1);
n2 = getPerlin(of + 16);
n2 += rxf * (getPerlin(of + 16 + 1) - n2);
n1 += ryf * (n2 - n1);
of += 256;
n2 = getPerlin(of);
n2 += rxf * (getPerlin(of + 1) - n2);
n3 = getPerlin(of + 16);
n3 += rxf * (getPerlin(of + 16 + 1) - n3);
n2 += ryf * (n3 - n2);
n1 += scaled_cosine(zf) * (n2 - n1);
r += n1 * ampl;
ampl *= 0.5;
xi <<= 1;
xf *= 2.0;
yi <<= 1;
yf *= 2.0;
zi <<= 1;
zf *= 2.0;
if (xf >= 1.0) {
xi++;
xf--;
}
if (yf >= 1.0) {
yi++;
yf--;
}
if (zf >= 1.0) {
zi++;
zf--;
}
}
return r;
}
`,
'void beforeVertex':`(){
vec2 q = aPosition.xy + 0.5;
vec2 shift = vec2(0.6, 0.8)*uTime;
float h = uTime;
q += shift;
noiseValue = p5noise(q.x, q.y, h);
noiseVector = vec4(
p5noise(q.x+dt, q.y, h), p5noise(q.x-dt, q.y, h),
p5noise(q.x, q.y+dt, h), p5noise(q.x, q.y-dt, h)
);
vNoise = 0.5*p5noise(q.x,q.y,h) + 0.25*p5noise(q.x*2.0,q.y*2.0,h*2.0) + 0.125*p5noise(q.x*4.0,q.y*4.0,h*4.0)+0.0625*p5noise(q.x*8.0,q.y*8.0,h*8.0);
}`,
'vec3 getLocalPosition':`(vec3 p){
p.z = 100.0*noiseValue-50.0;
return p;
}`,
'vec3 getLocalNormal':`(vec3 n){
n = normalize(
vec3(
-(noiseVector.x - noiseVector.y) / (2.0*dt),
-(noiseVector.z - noiseVector.w) / (2.0*dt),
1.0
)
);
return n;
}`,
'fragmentDeclarations':`IN float vNoise;`,
'Inputs getPixelInputs':`(Inputs inputs){
float intensity = smoothstep(0.3, 0.7, vNoise);
vec3 baseColor = vec3(0.0, 0.5, 1.0);
inputs.color.xyz = vec3(intensity)+baseColor-vec3(intensity)*baseColor;
inputs.ambientMaterial.xyz = intensity*baseColor;
return inputs;
}`
});
camera(100,100,100,0,0,0,0,0,-1);
perspective(PI/3,width/height,1, 2000);
}
function draw() {
const t = millis()/1000;
orbitControl();
noStroke();
shader(sh);
sh.setUniform("uTime", t);
sh.setUniform("uRandom", randomTex);
background(0);
const cam = this._renderer._curCamera;
lights();
specularMaterial(128);
plane(100,100,100,100);
}
function createRandomTableArray(w, h){
const ab = new ArrayBuffer(w*h*4);
const view = new DataView(ab);
for(let k=0; k<w*h; k++){
const r = random();
view.setFloat32(4*k, r, true); // バイト単位なので注意
}
const ca = new Uint8ClampedArray(w*h*4);
for(let i=0; i<w*h*4; i++){
ca[i] = view.getUint8(i);
}
const imd = new ImageData(ca, w, h);
// バイト列を無修正で格納
const gl = this._renderer.GL;
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
const result = new p5.Texture(this._renderer, imd);
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
return result; // おわりです
}
実行結果:
小数をバイト変換して色にする
p5のnoiseはjsの64bitFloatを使っているので厳密ではないのですが、Float32でもそれなりに近い精度の物は作れます。まあ64bitをGLSLで使うこともできるんでしょうが普通に面倒だと思います...
次のコードでテクスチャを作っています。ただ、内容的には色が格納されています。個々の色は0~1のランダムバリューに対応しています。
function createRandomTableArray(w, h){
const ab = new ArrayBuffer(w*h*4);
const view = new DataView(ab);
for(let k=0; k<w*h; k++){
const r = random();
view.setFloat32(4*k, r, true); // バイト単位なので注意
}
const ca = new Uint8ClampedArray(w*h*4);
for(let i=0; i<w*h*4; i++){
ca[i] = view.getUint8(i);
}
const imd = new ImageData(ca, w, h);
// バイト列を無修正で格納
const gl = this._renderer.GL;
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
const result = new p5.Texture(this._renderer, imd);
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
return result; // おわりです
}
w と h は今回共に64ですが一応一般論として扱っています。まずFloat32は4バイトですから4whバイトのバッファを作りましょう。arrayBufferさんの出番です。これのDataViewを作っておきます。バッファに読み書きするための媒体です。setFloat32で各4バイト領域にrandom()で取得したfloatを格納します。これはバイト単位でオフセットを指定するので4倍するのを忘れないでください。trueは必須です。リトルエンディアンです。型付配列がリトルなので合わせようというわけです。
Float32を入れ終わったら今度はUint8ClampedArrayを同じ長さで作りましょう。ここへさっきバッファに書き込んだバイト値を全部放り込みます。最後にここからImageDataを生成します。横と縦の幅にw,hを指定します。
これでコンストラクタの引数ができたので。p5.Textureを生成するのですが、注意があります。p5.jsはテクスチャ生成時のデフォルトがアルファ乗算になっているため、普通に生成するとR,G,Bに該当するバイト値がAを掛けた値になってしまいます。そこで一時的にそのオプションを切っています。こうすることでR,G,B,Aがきっかりそのままの値で格納されます。
// バイト列を無修正で格納
const gl = this._renderer.GL;
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
const result = new p5.Texture(this._renderer, imd);
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
arrayBufferやDataViewの取り扱いについてはこちらの記事でも扱っています。参考:
base64文字列とfloat32の相互変換
arrayBufferはただのバッファなので(バイト単位で連続して並んでいるのを配列と表現しているだけ)、それ単体では読み書きはできません。型付配列を使うか、DataViewを生成してそれによって読み込み、書き込みを実行する必要があるのです。
Float32に復元する
パーリンノイズの生成方法はp5.jsのnoiseのライブラリに書いてあるので詳細は省きますが、具体的には64x64個の乱数を必要とします。次の関数でそれを取得しています。今回はVTF,つまりバーテックスシェーダでノイズを使うので、vertexDeclarationsで作業します。場合によってはフラグメントシェーダで作業するかもですね。
// color -> float
float convertToFloat(vec4 c){
uint r = uint(round(c.r*255.0));
uint g = uint(round(c.g*255.0));
uint b = uint(round(c.b*255.0));
uint a = uint(round(c.a*255.0));
return uintBitsToFloat(r|(g<<8)|(b<<16)|(a<<24));
}
// Perlinの取得
float getPerlin(int n){
n = n & 4095;
float i = float(n);
float x = mod(i, 64.0) + 0.5;
float y = floor(i / 64.0) + 0.5;
return convertToFloat(texture(uRandom, vec2(x, y) / 64.0));
}
getPerlinで最終的に該当する位置の乱数値を取得しているんですが、取得しているのはまず色です。これをconvertToFloatでFloat32に変換します。リトルエンディアンで格納したので、それに基づいてFloat32に変換しています。uintBitsToFloatがそのための関数です。
テラインについて
描画に使うのは100x100の大きさのディテールが100x100の平面です。
plane(100,100,100,100);
これの頂点の位置と法線をバーテックスシェーダで書き換えてテラインとします。
'void beforeVertex':`(){
vec2 q = aPosition.xy + 0.5;
vec2 shift = vec2(0.6, 0.8)*uTime;
float h = uTime;
q += shift;
noiseValue = p5noise(q.x, q.y, h);
noiseVector = vec4(
p5noise(q.x+dt, q.y, h), p5noise(q.x-dt, q.y, h),
p5noise(q.x, q.y+dt, h), p5noise(q.x, q.y-dt, h)
);
vNoise = noiseValue;
}`,
'vec3 getLocalPosition':`(vec3 p){
p.z = 100.0*noiseValue;
return p;
}`,
'vec3 getLocalNormal':`(vec3 n){
n = normalize(
vec3(
-(noiseVector.x - noiseVector.y) / (2.0*dt),
-(noiseVector.z - noiseVector.w) / (2.0*dt),
1.0
)
);
return n;
}`,
planeのattribute変数はプラマイ0.5なので、0.5を足して0~1にします。これと時間変数、さらにノイズで、変位を出します。法線の出し方については一般的な曲面の法線の式にのっとっています。
z値ですが、平面の場合、drawBuffersScaledの z に該当する係数が1なので、 z に関しては値がそのまま反映されます。したがって、ずらす際には大きめの値が必要になってきます。ここでは100としました。
色について
法線の書き換えには成功したと思うのですが、いまいちライティングの効果が実感できなかったので、ノイズ値などを駆使して雑に書き換えてしまいました。この辺はいくらでもアレンジできるかと思います。位置の変更には成功してるっぽいので、ノイズ関数の実装には成功してると思われます。
おわりに
GLSLのノイズ関数は多岐にわたるので、いろいろ探してみると面白いかもです。ここまでお読みいただいてありがとうございました。これを使うと2Dのノイズを使った描画をより高速に実行できるかもしれません。詳しくはまたの機会に...(いつか動的更新とかもやりたいですね)