Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Three.js Custom Shader

Note that much of this code as changed as of edit 3 below.

So I really like a blog post by Brandon Jones (found here). I wanted to convert his code to Three.js, but I am having some issues. You can find his full code here. Here is my attempt so far, with a couple comments for questions I have:

// Shader
var tilemapVS = [
    "attribute vec2 pos;",
    "attribute vec2 texture;",

    "varying vec2 pixelCoord;",
    "varying vec2 texCoord;",

    "uniform vec2 viewOffset;",
    "uniform vec2 viewportSize;",
    "uniform vec2 inverseTileTextureSize;",
    "uniform float inverseTileSize;",

    "void main(void) {",
    "   pixelCoord = (texture * viewportSize) + viewOffset;",
    "   texCoord = pixelCoord * inverseTileTextureSize * inverseTileSize;",
    "   gl_Position = vec4(pos, 0.0, 1.0);",
    "}"
].join("\n");

var tilemapFS = [
    "precision highp float;",

    "varying vec2 pixelCoord;",
    "varying vec2 texCoord;",

    "uniform sampler2D tiles;",
    "uniform sampler2D sprites;",

    "uniform vec2 inverseTileTextureSize;",
    "uniform vec2 inverseSpriteTextureSize;",
    "uniform float tileSize;",
    "uniform int repeatTiles;",

    "void main(void) {",
    "   if(repeatTiles == 0 && (texCoord.x < 0.0 || texCoord.x > 1.0 || texCoord.y < 0.0 || texCoord.y > 1.0)) { discard; }",
    "   vec4 tile = texture2D(tiles, texCoord);",
    "   if(tile.x == 1.0 && tile.y == 1.0) { discard; }",
    "   vec2 spriteOffset = floor(tile.xy * 256.0) * tileSize;",
    "   vec2 spriteCoord = mod(pixelCoord, tileSize);",
    "   gl_FragColor = texture2D(sprites, (spriteOffset + spriteCoord) * inverseSpriteTextureSize);",
    //"   gl_FragColor = tile;",
    "}"
].join("\n");

this.material = new THREE.ShaderMaterial({
    attributes: {
        //not really sure what to use here, he uses some quadVertBuffer
        //for these values, but not sure how to translate.
        pos: { type: 'v2', value: new THREE.Vector2(0, 0) },
        texture: { type: 'v2', value: new THREE.Vector2(0, 0) }
    },
    uniforms: {
        viewportSize: { type: 'v2', value: new THREE.Vector2(viewport.width() / this.tileScale, viewport.height() / this.tileScale) },
        inverseSpriteTextureSize: { type: 'v2', value: new THREE.Vector2(1/tileset.image.width, 1/tileset.image.height) },
        tileSize: { type: 'f', value: this.tileSize },
        inverseTileSize: { type: 'f', value: 1/this.tileSize },

        tiles: { type: 't', value: tilemap },
        sprites: { type: 't', value: tileset },

        viewOffset: { type: 'v2', value: new THREE.Vector2(Math.floor(0), Math.floor(0)) },
        inverseTileTextureSize: { type: 'v2', value: new THREE.Vector2(1/tilemap.image.width, 1/tilemap.image.height) },
        //is 'i' the correct type for an int?
        repeatTiles: { type: 'i', value: 1 }
    },
    vertexShader: tilemapVS,
    fragmentShader: tilemapFS,
    transparent: false
});

/*this.material = new THREE.MeshBasicMaterial({
    color: 0xCC0000
})*/

this.plane = new THREE.PlaneGeometry(
    tilemap.image.width * this.tileSize * this.tileScale, //width
    tilemap.image.height * this.tileSize * this.tileScale//, //height
    //tilemap.image.width * this.tileScale, //width-segments
    //tilemap.image.height * this.tileScale //height-segments
);

this.plane.dynamic = true;

this.mesh = new THREE.Mesh(this.plane, this.material);

Once I load the page I get the following error:

TypeError: v1 is undefined
    customAttribute.array[ offset_custom ] = v1.x;

I'm sure this has to do with how I set the attributes, but i'm not sure what they should be. Any help is appreciated as there is little to no documentation on Custom Shaders in Three.js.

EDIT: Here is the code used in the blog post to fill the 2 attributes of the vertex shader (pos, and texture):

//in ctor
var quadVerts = [
    //x  y  u  v
    -1, -1, 0, 1,
     1, -1, 1, 1,
     1,  1, 1, 0,

    -1, -1, 0, 1,
     1,  1, 1, 0,
    -1,  1, 0, 0
];

this.quadVertBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, this.quadVertBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(quadVerts), gl.STATIC_DRAW);

this.tilemapShader = GLUtil.createProgram(gl, tilemapVS, tilemapFS);

//...

//then on the draw method
gl.bindBuffer(gl.ARRAY_BUFFER, this.quadVertBuffer);

gl.enableVertexAttribArray(shader.attribute.position);
gl.enableVertexAttribArray(shader.attribute.texture);
gl.vertexAttribPointer(shader.attribute.position, 2, gl.FLOAT, false, 16, 0);
gl.vertexAttribPointer(shader.attribute.texture, 2, gl.FLOAT, false, 16, 8);

I really don't fully understand exactly what is happening here, but if I am correct I think it is filling 2 Float32Arrays with half the data of the quadVertBuffer in each. Not only am I not sure why, I am not sure if I'm correct, nor do I know how t convert this to the Three.js method.


EDIT2: Right now I am using a plane to display the (2D) background, should I be using a sprite instead?


EDIT3:

So I got a little farther when I realized that Three.js will set position and uv vectors for me (which seems to be similar if not the same as position/texture in the above example). I also noticed that I may have had some types wrong since many of the 'v2' types I had (which invoke uniform2f) were actually being loaded via uniform2fv, so I changed those to 'v2v' and updated the value. Now I don't get the error, and it does paint something, just not quite the tilemap.

Here is the updated Vertex Shader:

var tilemapVS = [
    "varying vec2 pixelCoord;",
    "varying vec2 texCoord;",

    "uniform vec2 viewOffset;",
    "uniform vec2 viewportSize;",
    "uniform vec2 inverseTileTextureSize;",
    "uniform float inverseTileSize;",

    "void main(void) {",
    "   pixelCoord = (uv * viewportSize) + viewOffset;",
    "   texCoord = pixelCoord * inverseTileTextureSize * inverseTileSize;",
    "   gl_Position = vec4(position.x, position.y, 0.0, 1.0);",
    "}"
].join("\n");

and the updated Shader Material:

this._material = new THREE.ShaderMaterial({
    uniforms: {
        viewportSize: { type: 'v2v', value: [new THREE.Vector2(viewport.width() / this.tileScale, viewport.height() / this.tileScale)] },
        inverseSpriteTextureSize: { type: 'v2v', value: [new THREE.Vector2(1/tileset.image.width, 1/tileset.image.height)] },
        tileSize: { type: 'f', value: this.tileSize },
        inverseTileSize: { type: 'f', value: 1/this.tileSize },

        tiles: { type: 't', value: tilemap },
        sprites: { type: 't', value: tileset },

        viewOffset: { type: 'v2', value: new THREE.Vector2(0, 0) },
        inverseTileTextureSize: { type: 'v2v', value: [new THREE.Vector2(1/tilemap.image.width, 1/tilemap.image.height)] },
        repeatTiles: { type: 'i', value: 1 }
    },
    vertexShader: tilemapVS,
    fragmentShader: tilemapFS,
    transparent: false
});

And here is the result that I get:

enter image description here

Any ideas are welcome!


EDIT 4:

If I change the Vertex shader to use what I have found to be the "Three.js method" of setting gl_Position I can get even closer, but the offset is wrong in the sprite sheet. I think the pixelCoord varying is set wrong (since uv has slightly different values than texture I think).

I changed the Vertex Shader's main function to:

void main(void) {
   pixelCoord = (uv * viewportSize) + viewOffset;
   texCoord = pixelCoord * inverseTileTextureSize * inverseTileSize;
   gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}

and now I get actual tiles form the texture sheet, but the actual tile it chooses is wrong:

enter image description here

getting closer, any help is still appreciated.


EDIT 5:

I suspect this will be my last update, as I am close to having an answer. After setting tileset.flipY = false;, where tileset is the actual texture tiles, not the red map. I get all the right tiles landing in the right places; except they are all upside down!

Here is what it looks like after this change:

enter image description here

Is there some way to flip each individual texture over the Y axis (without editing the tileset image)? I feel like there is some simple vector math I could add to my shader to flip each texture it draws and finalize this.

I do note that if I don't flip both (tilemap.flipY = false; and tileset.flipY = false;) I get the right textures, int the right spots, fitting together correctly. But the entire map is upside down! so close...

enter image description here

like image 674
Chad Avatar asked Jul 02 '26 14:07

Chad


1 Answers

I managed to get this "working" although I do not consider it "fixed".

I flipped the tilemap (tilemap.flipY = true), unflipped the tileset (tileset.flipY = false) and then modified the mapmaker.html (that Toji wrote that will create these tilemaps) to draw each tile upside down on the sprite sheet (tileset).

I would MUCH prefer a different answer that actually fixes the problem instead of working around it like this, but for now this is my solution.

Below is the full relevant code.

Shader:

var tilemapVS = [
    "varying vec2 pixelCoord;",
    "varying vec2 texCoord;",

    "uniform vec2 viewOffset;",
    "uniform vec2 viewportSize;",
    "uniform vec2 inverseTileTextureSize;",
    "uniform float inverseTileSize;",

    "void main(void) {",
    "    pixelCoord = (uv * viewportSize) + viewOffset;",
    "    texCoord = pixelCoord * inverseTileTextureSize * inverseTileSize;",
    "    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);",
    "}"
].join("\n");

var tilemapFS = [
    //"precision highp float;",

    "varying vec2 pixelCoord;",
    "varying vec2 texCoord;",

    "uniform sampler2D tiles;",
    "uniform sampler2D sprites;",

    "uniform vec2 inverseTileTextureSize;",
    "uniform vec2 inverseSpriteTextureSize;",
    "uniform float tileSize;",
    "uniform int repeatTiles;",

    "void main(void) {",
    "    vec4 tile = texture2D(tiles, texCoord);", //load this pixel of the tilemap
    "    if(tile.x == 1.0 && tile.y == 1.0) { discard; }", //discard if R is 255 and G is 255
    "    vec2 spriteOffset = floor(tile.xy * 256.0) * tileSize;", //generate the offset in the tileset this pixel represents
    "    vec2 spriteCoord = mod(pixelCoord, tileSize);",
    "    vec4 texture = texture2D(sprites, (spriteOffset + spriteCoord) * inverseSpriteTextureSize);",
    "    gl_FragColor = texture;",
    "}"
].join("\n");

Shader Material (where tilemap and tileset are THREE.Textures):

//Setup Tilemap
tilemap.magFilter = THREE.NearestFilter;
tilemap.minFilter = THREE.NearestMipMapNearestFilter;
if(this.repeat) {
    tilemap.wrapS = tilemap.wrapT = THREE.RepeatWrapping;
} else {
    tilemap.wrapS = tilemap.wrapT = THREE.ClampToEdgeWrapping;
}

//Setup Tileset
tileset.wrapS = tileset.wrapT = THREE.ClampToEdgeWrapping;
tileset.flipY = false;
if(this.filtered) {
    tileset.magFilter = THREE.LinearFilter;
    tileset.minFilter = THREE.LinearMipMapLinearFilter;
} else {
    tileset.magFilter = THREE.NearestFilter;
    tileset.minFilter = THREE.NearestMipMapNearestFilter;
}

//setup shader uniforms
this.offset = new THREE.Vector2(0, 0);
this._uniforms = {
    viewportSize: { type: 'v2', value: new THREE.Vector2(viewport.width / this.tileScale, viewport.height / this.tileScale) },
    inverseSpriteTextureSize: { type: 'v2', value: new THREE.Vector2(1/tileset.image.width, 1/tileset.image.height) },
    tileSize: { type: 'f', value: this.tileSize },
    inverseTileSize: { type: 'f', value: 1/this.tileSize },

    tiles: { type: 't', value: tilemap },
    sprites: { type: 't', value: tileset },

    viewOffset: { type: 'v2', value: this.offset },
    inverseTileTextureSize: { type: 'v2', value: new THREE.Vector2(1/tilemap.image.width, 1/tilemap.image.height) },
    repeatTiles: { type: 'i', value: this.repeat ? 1 : 0 }
};

//create the shader material
this._material = new THREE.ShaderMaterial({
    uniforms: this._uniforms,
    vertexShader: tilemapVS,
    fragmentShader: tilemapFS,
    transparent: false
});

this._plane = new THREE.PlaneGeometry(viewport.width, viewport.height, this.tileSize, this.tileSize);

this._mesh = new THREE.Mesh(this._plane, this._material);

Modified Mapmaker portion:

MapMaker.prototype.processTile = function(x, y) {
    //rotate upside down, and draw
    this.tileCtx.save();
    this.tileCtx.translate(0, this.tileSize);
    this.tileCtx.scale(1, -1);
    this.tileCtx.drawImage(this.srcImage, 
        x * this.tileSize, y * this.tileSize, this.tileSize, this.tileSize, 
        0, 0, this.tileSize, this.tileSize);

    var sprite = this.cacheSprite();

    this.tileCtx.restore();

    this.mapCtx.fillStyle="rgb(" + sprite.x + "," + sprite.y + ", 0)";
    this.mapCtx.fillRect(x,y,1,1);

    /* Why was this thing drawing 2 times?
    this.tileCtx.drawImage(this.srcImage, 
        x * this.tileSize, y * this.tileSize, this.tileSize, this.tileSize, 
        0, 0, this.tileSize, this.tileSize);*/
};

If anyone has a different answer, please do not be shy to post it.

like image 131
Chad Avatar answered Jul 04 '26 04:07

Chad