ドット絵を立体に!! PixelArtGeometry 作ってみたよ


Three.jsには画像を立体化させるジオメトリがない?

Three.jsにはTextGemetryというフォントを立体化するジオメトリがあって便利ですよね。

でも2D画像をマインクラフトのアイテムみたいにシンプルに立体化させるジオメトリがないかとググってみましたが見つかりませんでした。


ないならば作ればいいじゃない

いままで3Dプログラミングにあまり触れたことがなかったので、難しい用語に四苦八苦しながら作ってみたのがこちら。

Heart_bg.png これが

↓ ↓ ↓ ↓ ↓ ↓

rendered_heart.png こうなる!


使い方

Three.jsとPixelArtGeometry.jsをあらかじめロードしておきます。

<!DOCTYPE html>

<html>
<head>
<meta charset="UTF-8" />
<script src="three.js"></script>
<script src="OrbitControls.js"></script>
<script src="PixelArtGeometry.js"></script>
<style>
body {
background-color: black;
}
</style>
</head>
<body>
<canvas id="myCanvas"></canvas> //キャンバス
<img id="test" src="./textures/heart.png" style="display:none"/> //表示したい画像
</body>
</html>


index.js

document.addEventListener("DOMContentLoaded", function(e){

const width = 400;
const height = 400;

// レンダラの設定
const renderer = new THREE.WebGLRenderer({
canvas: document.querySelector('#myCanvas'),
antialias: false
});
renderer.setClearColor(0xFFFFFF, 1);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(width, height);
// renderer.gammaInput = true; //色味がおかしくなるので使わない事
// renderer.gammaOutput = true; //

// シーンの初期化
const scene = new THREE.Scene();

// カメラの初期化
var viewSize = 25.4;
var aspectRatio = width / height;
const camera = new THREE.OrthographicCamera(-aspectRatio * viewSize / 2, aspectRatio * viewSize / 2, viewSize / 2, -viewSize / 2, -1000, 1000);
camera.position.set( viewSize, viewSize * 0.8168, viewSize ); // 0.8168: Isometric projection
camera.lookAt(scene.position);

// オービットコントロール(お好みで)
controls = new THREE.OrbitControls(camera, renderer.domElement);

//-------------------------------------------------------------------------------------
//canvasに画像を読み込んで貼り付けます。透過ピクセルを持つ、PNG, GIF, SVGなどを読み込んでください。
var img = document.querySelector("#heart");
var texture_canvas = document.createElement("canvas");
canvas.setAttribute("width", img.width);
canvas.setAttribute("height", img.height);
var context = canvas.getContext('2d');
context.imageSmoothingEnabled = false;
context.beginPath();
context.clearRect(0, 0, img.width, img.height);
context.drawImage(img, 0, 0, img.width, img.height, 0, 0, img.width, img.height);

//-------------------------------------------------------------------------------------
//THREE.Mesh のコンストラクタの第一引数にセットして、好きなマテリアルを第二引数にセットしてください。
// マテリアルに vertexColors: THREE.FaceColors を指定します。指定しないと真っ白になります。
const Heart = new THREE.Mesh(
new THREE.PixelArtGeometry(canvas /*キャンバス*/, 1 /*側面の幅(ピクセル)*/),
new THREE.MeshLambertMaterial({vertexColors: THREE.FaceColors, })
);
Heart.scale.set(20, 20, 20); // 拡大
Heart.rotation.set(0, 15 * (Math.PI / 180), 0); //回転
scene.add(Heart); //シーンに追加

// ライティング -------------------------------------------------------------------------------------
const night = false;
const dirLight = new THREE.DirectionalLight(night ? 0x9999cc: 0xB4B4B4);
var d = 50;
dirLight.position.set(-1, 2.25, 1).normalize();
dirLight.castShadow = true;
dirLight.shadow.mapSize.width = 2048;
dirLight.shadow.mapSize.height = 2048;
dirLight.shadow.camera.left = -d;
dirLight.shadow.camera.right = d;
dirLight.shadow.camera.top = d;
dirLight.shadow.camera.bottom = -d;
dirLight.shadow.camera.far = 3500;
dirLight.shadow.bias = -0.0001;
scene.add(dirLight);
var ambientLight = new THREE.AmbientLight(night ? 0x333333: 0x5D5D5D );
scene.add( ambientLight );

// Run tick -------------------------------------------------------------------------------------
tick();

function tick() {
// OrbitControl用。省略可
requestAnimationFrame(tick);

// Rendering
renderer.render(scene, camera);
}
});



ソースコード


PixelArtGeometry.js

THREE.PixelArtGeometry = function (canvas, depth) {

THREE.Geometry.call( this );
let ctx = canvas.getContext('2d'),
w = canvas.width,
h = canvas.height,
z = depth / w;
let data = ctx.getImageData(0, 0, w, h).data;
for(let x=0;x<w;x++) {
for(let y=0;y<h;y++) {
let d = (x + y * data.width) * 4;
if(data.data[d+3]) {
let color = new THREE.Color(data.data[d]<<16 ^ data.data[d+1]<<8 ^ data.data[d+2]);
color.offsetHSL(0, 0, 1-data.data[d+3]/255);
let chk = {
right: x+1>=w || !data.data[(x+1 + (y ) * data.width) * 4 + 3],
left: x-1<0 || !data.data[(x-1 + (y ) * data.width) * 4 + 3],
top: y+1>=h || !data.data[(x + (y+1) * data.width) * 4 + 3],
bottom: y-1<0 || !data.data[(x + (y-1) * data.width) * 4 + 3],
};

// front face
let v = [ // vertex indices. (array.push() returns array.length)
this.vertices.push(new THREE.Vector3(x/w, -y/w, z)) -1,
this.vertices.push(new THREE.Vector3((x+1)/w, -y/w, z)) -1,
this.vertices.push(new THREE.Vector3(x/w, -(y+1)/w, z)) -1,
this.vertices.push(new THREE.Vector3((x+1)/w, -(y+1)/w, z)) -1,
];
this.faces.push(new THREE.Face3(v[2], v[1], v[0], THREE.vertexNormals, color));
this.faces.push(new THREE.Face3(v[1], v[2], v[3], THREE.vertexNormals, color));

// back face
v = [
this.vertices.push(new THREE.Vector3(x/w, -y/w, 0)) -1,
this.vertices.push(new THREE.Vector3((x+1)/w, -y/w, 0)) -1,
this.vertices.push(new THREE.Vector3(x/w, -(y+1)/w, 0)) -1,
this.vertices.push(new THREE.Vector3((x+1)/w, -(y+1)/w, 0)) -1,
];
this.faces.push(new THREE.Face3(v[0], v[1], v[2], THREE.vertexNormals, color));
this.faces.push(new THREE.Face3(v[3], v[2], v[1], THREE.vertexNormals, color));

if(chk.right){
v = [
this.vertices.push(new THREE.Vector3((x+1)/w, -y/w, z)) -1,
this.vertices.push(new THREE.Vector3((x+1)/w, -y/w, 0)) -1,
this.vertices.push(new THREE.Vector3((x+1)/w, -(y+1)/w, z)) -1,
this.vertices.push(new THREE.Vector3((x+1)/w, -(y+1)/w, 0)) -1,
];
this.faces.push(new THREE.Face3(v[2], v[1], v[0], THREE.vertexNormals, color));
this.faces.push(new THREE.Face3(v[1], v[2], v[3], THREE.vertexNormals, color));
}

if(chk.left){
v = [
this.vertices.push(new THREE.Vector3(x/w, -y/w, z)) -1,
this.vertices.push(new THREE.Vector3(x/w, -y/w, 0)) -1,
this.vertices.push(new THREE.Vector3(x/w, -(y+1)/w, z)) -1,
this.vertices.push(new THREE.Vector3(x/w, -(y+1)/w, 0)) -1,
];
this.faces.push(new THREE.Face3(v[0], v[1], v[2], THREE.vertexNormals, color));
this.faces.push(new THREE.Face3(v[3], v[2], v[1], THREE.vertexNormals, color));
}

if(chk.top){
v = [
this.vertices.push(new THREE.Vector3(x/w, -(y+1)/w, z)) -1,
this.vertices.push(new THREE.Vector3((x+1)/w, -(y+1)/w, z)) -1,
this.vertices.push(new THREE.Vector3(x/w, -(y+1)/w, 0)) -1,
this.vertices.push(new THREE.Vector3((x+1)/w, -(y+1)/w, 0)) -1,
];
this.faces.push(new THREE.Face3(v[2], v[1], v[0], THREE.vertexNormals, color));
this.faces.push(new THREE.Face3(v[1], v[2], v[3], THREE.vertexNormals, color));
}

if(chk.bottom){
v = [
this.vertices.push(new THREE.Vector3(x/w, -y/w, z)) -1,
this.vertices.push(new THREE.Vector3((x+1)/w, -y/w, z)) -1,
this.vertices.push(new THREE.Vector3(x/w, -y/w, 0)) -1,
this.vertices.push(new THREE.Vector3((x+1)/w, -y/w, 0)) -1,
];
this.faces.push(new THREE.Face3(v[0], v[1], v[2], THREE.vertexNormals, color));
this.faces.push(new THREE.Face3(v[3], v[2], v[1], THREE.vertexNormals, color));
}
}
}
}
this.center();
this.mergeVertices();
this.computeFaceNormals();
};

THREE.PixelArtGeometry.prototype = Object.create(THREE.Geometry.prototype);
THREE.PixelArtGeometry.prototype.constructor = THREE.PixelArtGeometry;



解説

image.png

Context2DのgetImageDataを使ってピクセルの色情報を拾い、透過度が0でない場合、6個の立方体の頂点座標を作ってverticesへ追加します。

それぞれの面には取得した色をそのまま付けています。

(半透明には対応しきれなかったので、とりあえず透過度を明るさにして色を変換しています)

image.pngチェック無し

このままでも見た目はいい感じなのですが、隣合わせの面の情報が無駄なのと、同じ位置の面同士が表示時にチラつくため、それを回避するように上下左右のピクセルをチェックして、透明の場合だけ側面を追加するようにしています。

image.pngチェックあり


苦労した点


verticesへの頂点座標の追加は反時計回りが鉄則?

頂点座標を追加してポリゴンフレームを作るまでは良かったのですが、面(Face3)を付けた時、面の向きがどうやっても裏側になってしまい、最初はマテリアルに.side=THREE.DoubleSide(両面を描画)を指定しないとダメでした。

色々ググった結果、verticesに頂点(Vector3)を反時計回りに追加していないと表向きにならないという謎セオリーがあることが発覚!!


表面の謎の白っぽさ・・・

image.pngこれ全部、赤(255,0,0)なんです

もう一つ、3日ほど悩んだ部分ですが、キャンバスから色を拾った時、やたら白みがかった色しか取れなくなる現象が続いていました。

ctx.getImageData(0, 0, w, h);の時点で既に色が変化している!?ということは、Three.jsが原因ではない?

ちなみに普通のテクスチャを張ったキューブだとこの現象がおきず、原因不明。

さらにさらに調べた結果、

    const renderer = new THREE.WebGLRenderer({

canvas: document.querySelector('#myCanvas'),
antialias: false
});
renderer.gammaInput = true; //<--------- こ い つ ら !!
renderer.gammaOutput = true;//<---------

この何の変哲もないスニペットをコピペしてきたのが原因でした。

このgammmaInput/Outputは色の取得/設定時にgammaFactorを使うかどうかの設定なのですが、HTML5のcanvasオブジェクトにも影響があるそうです。