2

I'm trying to generate basic tiles and stairs in HTML5 Canvas without using images.

Here's what I did until now:

tiles

but I'm trying to reproduce this:

img2

and I have no idea how to.



Here's my current code:

class IsometricGraphics {
    constructor(canvas, thickness) {
        this.Canvas = canvas;
        this.Context = canvas.getContext("2d");

        if(thickness) {
            this.thickness = thickness;
        } else {
            this.thickness = 2;
        }
    }

    LeftPanelWide(x, y, fillStyle) {
        this.Context.fillStyle = fillStyle;

        for(var i = 0; i < 16; i++) {
            this.Context.fillRect(x + i * 2, y + i * 1, 2, this.thickness * 4);
        }
    }

    RightPanelWide(x, y, fillStyle) {
        this.Context.fillStyle = fillStyle;

        for(var i = 0; i < 16; i++) {
            this.Context.fillRect(x + (i * 2), y + 15 - (i * 1), 2, this.thickness * 4);
        }
    }

    UpperPanelWide(x, y, fillStyle) {
        this.Context.fillStyle = fillStyle;

        for(var i = 0; i < 17; i++) {
            this.Context.fillRect(x + 16 + 16 - (i * 2), y + i - 2, i * 4, 1);
        }

        for(var i = 0; i < 16; i++) {
            this.Context.fillRect(x + i * 2, y + (32 / 2) - 1 + i, ((32 / 2) - i) * 4, 1);
        }
    }

    UpperPanelWideBorder(x, y, fillStyle) {
        this.Context.fillStyle = fillStyle;

        var y = y + 2;

        for(var i = 0; i < 17; i++) {
            this.Context.fillRect(x + 17 + 16 - (i * 2) - 2, y + i - 2, (i == 17) ? 1 : 2, 1);
            this.Context.fillRect(x + 17 + 16 + (i * 2) - 2, y + i - 2, (i == 17) ? 1 : 2, 1);
        }

        for(var i = 0; i < 32 / 2; i++) {
            this.Context.fillRect(x + i * 2, y + 16 - 1 + i, 2, 1);
            this.Context.fillRect(x + 62 - i * 2, y + 16 - 1 + i, 2, 1);
        }
    }

    RightUpperPanelSmall(x, y, fillStyle) {
        this.Context.fillStyle = fillStyle;

        for(var i = 0; i < 32 / 2 + 4; i++) {
            this.Context.fillRect(x + (i * 2), (i >= 4) ? (i - 1) + y : 3 - i + 3 + y, 2, (i >= 4) ? (i <= 20 - 5) ? 8 : (20 - i) * 2 - 1 : 1 + (i * 2));
        }
    }

    LeftUpperPanelSmall(x, y, fillStyle) {
        this.Context.fillStyle = fillStyle;

        for(var i = 0; i < 32 / 2 + 4; i++) {
            this.Context.fillRect(x + (i * 2), (i >= 16) ? y + (i - 16) : 16 + y - (i * 1) - 1, 2, (i >= 4) ? (i >= 16) ? 8 - (i - 16) - (i - 16) - 1 : 8 : 8 * i - (i * 6) + 1);
        }
    }

    LeftPanelSmall(x, y, fillStyle) {
        this.Context.fillStyle = fillStyle;

        for(var i = 0; i < 8 / 2; i++) {
            this.Context.fillRect(x + i * 2, y + i * 1, 2, this.thickness * 4);
        }
    }

    RightPanelSmall(x, y, fillStyle) {
        this.Context.fillStyle = fillStyle;

        for(var i = 0; i < 8 / 2; i++) {
            this.Context.fillRect(x + (i * 2), y + 3 - (i * 1), 2, this.thickness * 4);
        }
    }
}


class IsoGenerator {
    constructor() {
        var Canvas = document.querySelector("canvas");
        var Context = Canvas.getContext("2d");

        //Context.scale(5, 5);
        this.Context = Context;
        this.IsometricGraphics = new IsometricGraphics(Canvas, 2);
    }

    StairLeft(x, y, Color1, Color2, Color3) {
        for(var i = 0; i < 4; i++) {
            this.IsometricGraphics.RightPanelWide((x + 8) + (i * 8), (y + 4) + (i * 12), Color1);
            this.IsometricGraphics.LeftUpperPanelSmall(x + (i * 8), y + (i * 12), Color2);
            this.IsometricGraphics.LeftPanelSmall((i * 8) + x, (16 + (i * 12)) + y, Color3);
        }
    }

    StairRight(x, y, Color1, Color2, Color3) {
        for(var i = 0; i < 4; i++) {
            this.IsometricGraphics.LeftPanelWide(x + 24 - (i * 8), (4 + (i * 12)) + y, Color1);
            this.IsometricGraphics.RightUpperPanelSmall(x + 24 - (i * 8), y + (i * 12) - 3, Color2);
            this.IsometricGraphics.RightPanelSmall(x + 56 - (i * 8), (16 + (i * 12)) + y, Color3);
        }
    }

    Tile(x, y, Color1, Color2, Color3, Border) {
        this.IsometricGraphics.LeftPanelWide(x, 18 + y, Color1);
        this.IsometricGraphics.RightPanelWide(x + 32, 18 + y, Color2);
        this.IsometricGraphics.UpperPanelWide(x, 2 + y, Color3);

        if(Border) {
            this.IsometricGraphics.UpperPanelWideBorder(x, y, Border);
        }
    }
}

var Canvas = document.querySelector("canvas");
var Context = Canvas.getContext("2d");

Context.scale(3, 3);

new IsoGenerator().Tile(0, 0, "#B3E5FC", "#2196F3", "#03A9F4")
new IsoGenerator().StairLeft(70, 0, "#B3E5FC", "#2196F3", "#03A9F4")
new IsoGenerator().StairRight(70 * 2, 0, "#B3E5FC", "#2196F3", "#03A9F4")

// What I'm trying to reproduce: http://i.imgur.com/YF4xyz9.png
<canvas width="1000" height="1000"></canvas>

Fiddle: https://jsfiddle.net/xvak0jh1/2/

Matt
  • 5,315
  • 1
  • 30
  • 57
JeePing
  • 449
  • 1
  • 6
  • 15
  • First of all, neat! My first idea is to think of it as a bunch of your first platforms stacked and each one gets smaller. I am working on an answer based on your code now. – Matt Jun 29 '17 at 21:47
  • If you don't get an answer before I have time to work on this more tonight, here's the direction that I'm going. `new IsoGenerator().Stack(0, 0, 4, "#B3E5FC", "#2196F3", "#03A9F4")` and then Tile needs to take more than x, y. The z index of stack is the number of Tiles. – Matt Jun 29 '17 at 21:57
  • @mkaatman Thanks! Why do you need z-indexing? Just use a reverse loop like `for(var i = 4; i >= 0; i--)` and then the smallest tile/platform would be drawn at last – JeePing Jun 29 '17 at 22:15
  • @mkaatman I did some experiments if this can help you https://jsfiddle.net/xvak0jh1/1/ – JeePing Jun 29 '17 at 22:22
  • I guess I was thinking you would start at the top and you could make as tall of a stack as you'd like. If you will only ever want 4 then you probably can hard code it. – Matt Jun 29 '17 at 22:22
  • @mkaatman https://jsfiddle.net/xvak0jh1/3/ edited sorry. Yeah I only need 4 so it's hard coded in my fiddle – JeePing Jun 29 '17 at 22:24
  • I messed around with this a bit tonight but I'm in over my head. I'd need to rewrite all these formulas with human readable variables. If you created methods for left, right, upper panels that took a x/y position as well as a height and width I think you'd be on the right track. I really like this question. I may open a bounty on it tomorrow. – Matt Jun 30 '17 at 05:17
  • this problem was solved in the late 1970's and has been asked about here repeatedly. –  Jun 30 '17 at 14:06

1 Answers1

3

Axonometric rendering

The best way to handle axonometric (commonly called isometric) rendering is by modeling the object in 3D and then render the model in the particular axonometric projection you want.

3D object as a Mesh

The most simple object (in this case) is a box. The box has 6 sides and 8 vertices and can be described via its vertices and the polygons representing the sides as a set of indexes to the vertices.

Eg 3D box with x from left to right, y going top to bottom, and z as up.

First create the vertices that make up the box

UPDATE as requested in the comments I have changed the box into its x,y,z dimensions.

// function creates a 3D point (vertex)
function vertex(x,y,z){ return {x,y,z} };
// an array of vertices
const vertices = []; // an array of vertices

// create the 8 vertices that make up a box

const boxSizeX = 10;   // size of the box x axis
const boxSizeY = 50;   // size of the box y axis
const boxSizeZ = 8;   // size of the box z axis
const hx = boxSizeX / 2; // half size shorthand for easier typing
const hy = boxSizeY / 2; 
const hz = boxSizeZ / 2; 

vertices.push(vertex(-hx,-hy,-hz)); // lower top left  index 0
vertices.push(vertex( hx,-hy,-hz)); // lower top right
vertices.push(vertex( hx, hy,-hz)); // lower bottom right
vertices.push(vertex(-hx, hy,-hz)); // lower bottom left
vertices.push(vertex(-hx,-hy, hz)); // upper top left  index 4
vertices.push(vertex( hx,-hy, hz)); // upper top right
vertices.push(vertex( hx, hy, hz)); // upper bottom right
vertices.push(vertex(-hx, hy, hz)); // upper  bottom left index 7

Then create the polygons for each face on the box

const colours = {
    dark : "#444",
    shade : "#666",
    light : "#aaa",
    bright : "#eee",
}
function createPoly(indexes,colour){ return { indexes, colour} }
const polygons = [];
// always make the polygon vertices indexes in a clockwise direction
// when looking at the polygon from the outside of the object
polygons.push(createPoly([3,2,1,0],colours.dark)); // bottom face
polygons.push(createPoly([0,1,5,4],colours.dark)); // back face
polygons.push(createPoly([1,2,6,5],colours.shade)); // right face
polygons.push(createPoly([2,3,7,6],colours.light)); // front face
polygons.push(createPoly([3,0,4,7],colours.dark)); // left face
polygons.push(createPoly([4,5,6,7],colours.bright)); // top face

Now you have a 3D model of a box with 6 polygons.

Projection

The projection describes how a 3D object is transformed into a 2D projection. This is done by providing a 2D axis for each of the 3D coordinates.

In this case you are using a modification of a bimetric projection

So lets define that 2D axis for each of the 3 3D coordinates.

  // From here in I use P2,P3 to create 2D and 3D points
  const P3 = (x=0, y=0, z=0) => ({x,y,z});
  const P2 = (x=0, y=0) => ({x, y});

  // an object to handle the projection
  const isoProjMat = {
      xAxis : P2(1 , 0.5) ,  // 3D x axis for every 1 pixel in x go down half a pixel in y
      yAxis :  P2(-1 , 0.5) , // 3D y axis for every -1 pixel in x go down half a pixel in y
      zAxis :  P2(0 , -1) , // 3D z axis go up 1 pixels
      origin : P2(100,100),  // where on the screen 3D coordinate (0,0,0) will be

Now define the function that does the projection by converting the x,y,z (3d) coordinate into a x,y (2d)

      project (p, retP = P2()) {
          retP.x = p.x * this.xAxis.x + p.y * this.yAxis.x + p.z * this.zAxis.x + this.origin.x;
          retP.y = p.x * this.xAxis.y + p.y * this.yAxis.y + p.z * this.zAxis.y + this.origin.y;
          return retP;
      }
  }

Rendering

Now you can render the model. First you must project each vertices into the 2D screen coordinates.

// create a new array of 2D projected verts
const projVerts = vertices.map(vert => isoProjMat.project(vert));

Then it is just a matter of rendering each polygon via the indexes into the projVerts array

polygons.forEach(poly => {
    ctx.fillStyle = poly.colour;
    ctx.beginPath();
    poly.indexs.forEach(index => ctx.lineTo(projVerts[index].x, projVerts[index].y) );
    ctx.fill();
});

As a snippet

const ctx = canvas.getContext("2d");

// function creates a 3D point (vertex)
function vertex(x, y, z) { return { x, y, z}};
// an array of vertices
const vertices = []; // an array of vertices

// create the 8 vertices that make up a box
const boxSizeX = 10 * 4;   // size of the box x axis
const boxSizeY = 50 * 4;   // size of the box y axis
const boxSizeZ = 8 * 4;   // size of the box z axis
const hx = boxSizeX / 2; // half size shorthand for easier typing
const hy = boxSizeY / 2; 
const hz = boxSizeZ / 2; 

vertices.push(vertex(-hx,-hy,-hz)); // lower top left  index 0
vertices.push(vertex( hx,-hy,-hz)); // lower top right
vertices.push(vertex( hx, hy,-hz)); // lower bottom right
vertices.push(vertex(-hx, hy,-hz)); // lower bottom left
vertices.push(vertex(-hx,-hy, hz)); // upper top left  index 4
vertices.push(vertex( hx,-hy, hz)); // upper top right
vertices.push(vertex( hx, hy, hz)); // upper bottom right
vertices.push(vertex(-hx, hy, hz)); // upper  bottom left index 7



const colours = {
  dark: "#444",
  shade: "#666",
  light: "#aaa",
  bright: "#eee",
}

function createPoly(indexes, colour) {
  return {
    indexes,
    colour
  }
}
const polygons = [];
// always make the polygon vertices indexes in a clockwise direction
// when looking at the polygon from the outside of the object
polygons.push(createPoly([3, 2, 1, 0], colours.dark)); // bottom face
polygons.push(createPoly([0, 1, 5, 4], colours.dark)); // back face
polygons.push(createPoly([3, 0, 4, 7], colours.dark)); // left face
polygons.push(createPoly([1, 2, 6, 5], colours.shade)); // right face
polygons.push(createPoly([2, 3, 7, 6], colours.light)); // front face
polygons.push(createPoly([4, 5, 6, 7], colours.bright)); // top face



// From here in I use P2,P3 to create 2D and 3D points
const P3 = (x = 0, y = 0, z = 0) => ({x,y,z});
const P2 = (x = 0, y = 0) => ({ x, y});

// an object to handle the projection
const isoProjMat = {
  xAxis: P2(1, 0.5), // 3D x axis for every 1 pixel in x go down half a pixel in y
  yAxis: P2(-1, 0.5), // 3D y axis for every -1 pixel in x go down half a pixel in y
  zAxis: P2(0, -1), // 3D z axis go up 1 pixels
  origin: P2(150, 75), // where on the screen 3D coordinate (0,0,0) will be
  project(p, retP = P2()) {
    retP.x = p.x * this.xAxis.x + p.y * this.yAxis.x + p.z * this.zAxis.x + this.origin.x;
    retP.y = p.x * this.xAxis.y + p.y * this.yAxis.y + p.z * this.zAxis.y + this.origin.y;
    return retP;
  }
}

// create a new array of 2D projected verts
const projVerts = vertices.map(vert => isoProjMat.project(vert));
// and render
polygons.forEach(poly => {
  ctx.fillStyle = poly.colour;
  ctx.beginPath();
  poly.indexes.forEach(index => ctx.lineTo(projVerts[index].x, projVerts[index].y));
  ctx.fill();
});
canvas {
  border: 2px solid black;
}
<canvas id="canvas"></canvas>

More

That is the basics, but by no means all. I have cheated by making sure that the order of the polygons is correct in terms of distance from the viewer. Ensuring that the further polygons are not drawn over the nearer. For more complex shapes you will need to add Depth sorting. You also want to optimise the rendering by not drawing faces (polygons) that face away from the viewer. This is called backface culling.

You will also want to add lighting models and much more.

Pixel Bimetric projection.

The above is in fact not what you want. In gaming the projection you use is often called a pixel art projection that does not fit the nice mathematical projection. The are many sets of rules concerning anti aliasing, where vertices are rendered depending on the direction of the face.

eg a vertex is drawn at a pixel top,left or top,right, or bottom,right, or bottom,left depending on the face direction, and alternating between odd and even x coordinates to name but a few of the rules

This pen Axonometric Text Render (AKA Isometric) is a slightly more complex example of Axonometric rendering that has options for 8 common axonometric projections and includes simple depth sorting, though not built for speed. This answer is what inspired writing the pen.

Your shape.

So after all that the next snippet draws the shape you are after by moving the basic box to each position and rendering it in order from back to front.

const ctx = canvas.getContext("2d");

// function creates a 3D point (vertex)
function vertex(x, y, z) { return { x, y, z}};
// an array of vertices
const vertices = []; // an array of vertices

// create the 8 vertices that make up a box
const boxSize = 20; // size of the box
const hs = boxSize / 2; // half size shorthand for easier typing

vertices.push(vertex(-hs, -hs, -hs)); // lower top left  index 0
vertices.push(vertex(hs, -hs, -hs)); // lower top right
vertices.push(vertex(hs, hs, -hs)); // lower bottom right
vertices.push(vertex(-hs, hs, -hs)); // lower bottom left
vertices.push(vertex(-hs, -hs, hs)); // upper top left  index 4
vertices.push(vertex(hs, -hs, hs)); // upper top right
vertices.push(vertex(hs, hs, hs)); // upper bottom right
vertices.push(vertex(-hs, hs, hs)); // upper  bottom left index 7



const colours = {
  dark: "#004",
  shade: "#036",
  light: "#0ad",
  bright: "#0ee",
}

function createPoly(indexes, colour) {
  return {
    indexes,
    colour
  }
}
const polygons = [];
// always make the polygon vertices indexes in a clockwise direction
// when looking at the polygon from the outside of the object
//polygons.push(createPoly([3, 2, 1, 0], colours.dark)); // bottom face
//polygons.push(createPoly([0, 1, 5, 4], colours.dark)); // back face
//polygons.push(createPoly([3, 0, 4, 7], colours.dark)); // left face
polygons.push(createPoly([1, 2, 6, 5], colours.shade)); // right face
polygons.push(createPoly([2, 3, 7, 6], colours.light)); // front face
polygons.push(createPoly([4, 5, 6, 7], colours.bright)); // top face



// From here in I use P2,P3 to create 2D and 3D points
const P3 = (x = 0, y = 0, z = 0) => ({x,y,z});
const P2 = (x = 0, y = 0) => ({ x, y});

// an object to handle the projection
const isoProjMat = {
  xAxis: P2(1, 0.5), // 3D x axis for every 1 pixel in x go down half a pixel in y
  yAxis: P2(-1, 0.5), // 3D y axis for every -1 pixel in x go down half a pixel in y
  zAxis: P2(0, -1), // 3D z axis go up 1 pixels
  origin: P2(150, 55), // where on the screen 3D coordinate (0,0,0) will be
  project(p, retP = P2()) {
    retP.x = p.x * this.xAxis.x + p.y * this.yAxis.x + p.z * this.zAxis.x + this.origin.x;
    retP.y = p.x * this.xAxis.y + p.y * this.yAxis.y + p.z * this.zAxis.y + this.origin.y;
    return retP;
  }
}
var x,y,z;
for(z = 0; z < 4; z++){
   const hz = z/2;
   for(y = hz; y < 4-hz; y++){
       for(x = hz; x < 4-hz; x++){
          // move the box
          const translated = vertices.map(vert => {
               return P3(
                   vert.x + x * boxSize, 
                   vert.y + y * boxSize, 
                   vert.z + z * boxSize, 
               );
          });
                   
          // create a new array of 2D projected verts
          const projVerts = translated.map(vert => isoProjMat.project(vert));
          // and render
          polygons.forEach(poly => {
            ctx.fillStyle = poly.colour;
            ctx.strokeStyle = poly.colour;
            ctx.lineWidth = 1;
            ctx.beginPath();
            poly.indexes.forEach(index => ctx.lineTo(projVerts[index].x , projVerts[index].y));
            ctx.stroke();
            ctx.fill();
            
          });
      }
   }
}
canvas {
  border: 2px solid black;
}
<canvas id="canvas"></canvas>
Blindman67
  • 51,134
  • 11
  • 73
  • 136
  • 1
    Really nice job. Is this from somewhere else or did you create it for this question? – Matt Jun 30 '17 at 05:27
  • @mkaatman I recently answered a question regarding isometric depth sorting, this is a rewrite of that code to suit this question. – Blindman67 Jun 30 '17 at 05:29
  • That's fantastic! I created a new "Axonometric" tag. Do you think that's fair to tag these kind of questions in that way? (Or is isometric good enough?) Also, any thoughts on how you'd achieve the pixelated effect he created by stacking rectangles? – Matt Jun 30 '17 at 05:30
  • @mkaatman Axonometric is not at all commonly known so I don't think it will get much in the way of questions. For the pixel art version the canvas is too slow and it is best done via shaders in webGL. I have licensed the code and can not publicly release – Blindman67 Jun 30 '17 at 05:38
  • @Blindman67 I am the OP as I don't have access to my account "JeePing" anymore because I had a problem with my chrome profile so I lost passwords (even of the assigned email to this account). I've sent a support request to validate your answer! Anyway, you did a really nice job! I've made a class from your code - check it out here https://jsfiddle.net/6hwggtc2/2/ if it can help anyone. Just a question, I don't really see a way to transform the cube class into a cuboid where I can set x,y,z dimensions instead of one parameter `Size`. Could you please provide a fiddle or something? – Skrey Jun 30 '17 at 13:06
  • I need to have a Cuboid class so I could easily draw stairs like in my post or walls – Skrey Jun 30 '17 at 13:13
  • @Skrey Any shape is easy to create by just adding vertices at the correct positions. Adding a 3D vertex via `vertices.push(vertex(-hs,-hs,-hs))` is `vertices.push(vertex(x, y, z))` with x down right, y down left, and z up. The polygons are then just indexes into the vertices array. It is compatible with most 3D mesh models, and thus editors, just read in the vertices, and the polygons as triangles indexing the verts. But you will need a much better depth sort as the 2D canvas does not provide a z-buffer, or just create from cubes using depth sort found in the linked examples. – Blindman67 Jun 30 '17 at 13:59
  • @Skrey I have updated the example first snippet to show different dimensions for x,y, and z; – Blindman67 Jun 30 '17 at 14:04