How to create a collision detection with 3D objects?

advertisements

I'm creating a re-make of atari 8bit game called spindizzy as my school project.

I figured out how to render it using casting 3d points to 2d perspective point.

I also can spin shapes (with minor problem of order of rendering).

My goal was something like this:

Vector3D -> Vector2D - done

Block -> [[Vector3D]] -> [[Vector2D]] -> [Shape] - done

Map -> [Location] -> [[[Block]]]

But I have no idea what should chandle colision detection, and how to chandle it.

My target language is js but it does not matter if given answer is in other language or just description of how to solve this problem.
Frameworks are not allowed.

I'm using 2d context of canvas.

Here you can see my code.

I'll be aslo grateful for the links and suggestions.


Surfaces and normals.

Ignoring the projection and working only in 3D space with some assumptions.

  • All blocks are aligned with the x,y axis.
  • All blocks have the height of each corner.
  • All blocks can have either one flat surface a quad, or be split into two triangles.
  • The Split blocks are either split from top left to bottom right or from top right to bottom left.
  • Quad (unsplit block) 4 height points must be on the same plane.
  • Blocks are always 1 unit width (x axis) and 1 unit in depth (y axis).
  • It is possible to have an invalid block. To be a valid block at least 2 points for each face must have the same height. A block with all height set to a different value is not a valid block. (this matches the conditions of the game in the video)

Each block is defined by one object that holds the position (x,y,z), corner heights, the type (split1, split2, or quad) and the surface norm/s

A single function will return the height of a point at x,y on the block. The blocks pNorm property will be set to the surface normal at that point. (Do not modify the normal. If you need to modify it create a copy first)

The surface normal is a line perpendicular to the plane. When you do a height test the property block.pNorm is set to the appropriate normal. The normal is used to determine the direction the ball should roll. (I have not included any z movement, the ball is stuck to the surface). The normal is also used to determine shading, and what direction the ball would bounce.

A Demo

The best way to explain is via a demo. There is a lot of code to get the demo happening so please do ask if you have any questions.

Note the code is written with a little ES6 so will need babel to run on legacy browsers.

UPDATE First post I had a bug I did not spot (normals were incorrectly set). I have fixed it now. I have also added an error that will throw a RangeError if the map contains a invalid block.

var canvas = document.createElement("canvas");
canvas.width = 400;
canvas.height = 300;
var ctx = canvas.getContext("2d");
document.body.appendChild(canvas);

// block types
const types = {
    quad : 1,
    split1 : 2, // split from top left to bottom right
    split2 : 3, // split from top right to bottom left
}
/*
// A block object example to define meaning of properties
var blockObject = {
    x : 0, // top left base x pos
    y : 0, // top left base y pos
    z : 0, // top left base z pos
    norm1, // normal of quad or top right or bottom right triangles
    norm2, // normal of quad or top left or bottom left triangles
    p1 : 0,  // top left
    p2 : 0,  // top right
    p3 : 0,  // bottom right
    p4 : 0,  // bottom left
    type : types.quad,
    pNorm : null, // this is set when a height test is done. It is the normal at the point of the height test
}*/

// compute the surface normal from two vectors on the surface. (cross product of v1,v2)
function getSurfaceNorm(x1,y1,z1,x2,y2,z2){
    // normalise vectors
    var d1= Math.hypot(x1,y1,z1);
    x1 /= d1;
    y1 /= d1;
    z1 /= d1;
    var d2= Math.hypot(x2,y2,z2);
    x2 /= d2;
    y2 /= d2;
    z2 /= d2;
    var norm = {}
    norm.x = y1 * z2 - z1 * y2;
    norm.y = z1 * x2 - x1 * z2;
    norm.z = x1 * y2 - y1 * x2;
    return norm;
}
// This defines a block with p1-p2 the height of the corners
// starting top left and clockwise around to p4 bottom left
// If the block is split with 2 slopes then it will be
// of type.split1 or type.split2. If a single slope then it is a type.quad
// Also calculates the normals
function createBlock(x,y,z,h1,h2,h3,h4,type){
    var norm1,norm2;
    if(type === types.quad){
        norm1 = norm2 = getSurfaceNorm(1, 0, h2 - h1, 0, 1, h4 - h1);
    }else if(type === types.split1){
        norm1 = getSurfaceNorm(1, 0, h2 - h1, 1, 1, h3 - h1);
        norm2 = getSurfaceNorm(0, 1, h2 - h1, 1, 1, h3 - h1);
    }else{
        norm1 = getSurfaceNorm(0, 1, h2-h3, 1, 0, h4 - h3);
        norm2 = getSurfaceNorm(1, 0, h2 - h1, 0, 1, h4 - h1);
    }
    return {
        p1 : h1,  // top left
        p2 : h2,  // top right
        p3 : h3,  // bottom right
        p4 : h4,  // bottom left
        x,y,z,type,
        norm1,norm2,
    }
}

// get the height on the block at x,y
// also sets the surface block.pNorm to match the correct normal
function getHeight(block,x,y){
    var b = block; // alias to make codes easier to read.
    if(b.type === types.quad){
        b.pNorm = b.norm1;
        if(b.p1 === b.p2){
            return (b.p3 - b.p1) * (y % 1) + b.p1 + b.z;
        }
        return (b.p2 - b.p1) * (x % 1) + b.p1 + b.z;
    }else if(b.type === types.split1){
        if(x % 1 > y % 1){ // on top right side
            b.pNorm = b.norm1;
            if(b.p1 === b.p2){
                if(b.p1 === b.p3){
                    return b.p1 + b.z;
                }
                return (b.p3 - b.p1) * (y % 1) + b.p1 + b.z;
            }
            if(b.p2 === b.p3){
                return (b.p2 - b.p1) * (x % 1) + b.p1 + b.z;
            }
            return (b.p3 - b.p2) * (y % 1) + (b.p2 - b.p1) * (x % 1) + b.p1 + b.z;
        }
        // on bottom left size
        b.pNorm = b.norm2;
        if(b.p3 === b.p4){
            if(b.p1 === b.p3){
                return b.p1 + b.z;
            }
            return (b.p3 - b.p1) * (y % 1) + b.p1 + b.z;
        }
        if(b.p1 === b.p4){
            return (b.p3 - b.p1) * (x % 1) + b.p1 + b.z;
        }
        var h = (b.p4 - b.p1) * (y % 1);
        var h1 = b.p3 - (b.p4 - b.p1) + h;
        return (h1 - (h + b.p1)) * (x % 1) + (h + b.p1) + b.z;
    }
    if(1 - (x % 1) < y % 1){ // on bottom right side
        b.pNorm = b.norm1;
        if(b.p3 === b.p4){
            if(b.p3 === b.p2){
                return b.p2 + b.z;
            }
            return (b.p3 - b.p2) * (y % 1) + b.p4 + b.z;
        }
        if(b.p2 === b.p3){
            return (b.p4 - b.p2) * (x % 1) + b.p2 + b.z;
        }
        var h = (b.p3 - b.p2) * (y % 1);
        var h1 = b.p4 - (b.p3 - b.p2) + h;
        return (h + b.p2 - h1) * (x % 1) + h1 + b.z;
    }
    // on top left size
    b.pNorm = b.norm2;
    if(b.p1 === b.p2){
        if(b.p1 === b.p4){
            return b.p1 + b.z;
        }
        return (b.p4 - b.p1) * (y % 1) + b.p1 + b.z;
    }
    if(b.p1 === b.p4){
        return (b.p2 - b.p1) * (x % 1) + b.p1 + b.z;
    }
    var h = (b.p4 - b.p1) * (y % 1);
    var h1 = b.p2 + h;
    return (h1 - (h + b.p1)) * (x % 1) + (h + b.p1) + b.z;

}
const projection = {
    width : 20,
    depth : 20, // y axis
    height : 8, // z axis
    xSlope : 0.5,
    ySlope : 0.5,
    originX : canvas.width / 2,
    originY : canvas.height / 4,
    toScreen(x,y,z,point = [],pos = 0){
        point[pos] = x * this.width - y * this.depth + this.originX;
        point[pos + 1] = x * this.width * this.xSlope + y * this.depth * this.ySlope -z * this.height + this.originY;
        return point;
    }
}
// working arrays to avoid excessive GC hits
var pointArray = [0,0]
var workArray = [0,0,0,0,0,0,0,0,0,0,0,0,0,0];
function drawBlock(block,col,lWidth,edge){
    var b = block;
    ctx.strokeStyle = col;
    ctx.lineWidth = lWidth;
    ctx.beginPath();
    projection.toScreen(b.x,     b.y,     b.z + b.p1, workArray, 0);
    projection.toScreen(b.x + 1, b.y,     b.z + b.p2, workArray, 2);
    projection.toScreen(b.x + 1, b.y + 1, b.z + b.p3, workArray, 4);
    projection.toScreen(b.x,     b.y + 1, b.z + b.p4, workArray, 6);
    if(b.type === types.quad){
        ctx.moveTo(workArray[0],workArray[1]);
        ctx.lineTo(workArray[2],workArray[3]);
        ctx.lineTo(workArray[4],workArray[5]);
        ctx.lineTo(workArray[6],workArray[7]);
        ctx.closePath();
    }else if(b.type === types.split1){
        ctx.moveTo(workArray[0],workArray[1]);
        ctx.lineTo(workArray[2],workArray[3]);
        ctx.lineTo(workArray[4],workArray[5]);
        ctx.closePath();
        ctx.moveTo(workArray[0],workArray[1]);
        ctx.lineTo(workArray[4],workArray[5]);
        ctx.lineTo(workArray[6],workArray[7]);
        ctx.closePath();
    }else if(b.type === types.split2){
        ctx.moveTo(workArray[0],workArray[1]);
        ctx.lineTo(workArray[2],workArray[3]);
        ctx.lineTo(workArray[6],workArray[7]);
        ctx.closePath();
        ctx.moveTo(workArray[2],workArray[3]);
        ctx.lineTo(workArray[4],workArray[5]);
        ctx.lineTo(workArray[6],workArray[7]);
        ctx.closePath();
    }
    if(edge){
        projection.toScreen(b.x + 1, b.y,     b.z, workArray, 8);
        projection.toScreen(b.x + 1, b.y + 1, b.z, workArray, 10);
        projection.toScreen(b.x,     b.y + 1, b.z, workArray, 12);
        if(edge === 1){ // right edge
            ctx.moveTo(workArray[2],workArray[3]);
            ctx.lineTo(workArray[8],workArray[9]);
            ctx.lineTo(workArray[10],workArray[11]);
            ctx.lineTo(workArray[4],workArray[5]);
        }
        if(edge === 2){ // right edge
            ctx.moveTo(workArray[4],workArray[5]);
            ctx.lineTo(workArray[10],workArray[11]);
            ctx.lineTo(workArray[12],workArray[13]);
            ctx.lineTo(workArray[6],workArray[7]);
        }
        if(edge === 3){ // right edge
            ctx.moveTo(workArray[2],workArray[3]);
            ctx.lineTo(workArray[8],workArray[9]);
            ctx.lineTo(workArray[10],workArray[11]);
            ctx.lineTo(workArray[12],workArray[13]);
            ctx.lineTo(workArray[6],workArray[7]);
            ctx.moveTo(workArray[10],workArray[11]);
            ctx.lineTo(workArray[4],workArray[5]);
        }

    }
    ctx.stroke();
}

function createMap(){
    var base = "0".charCodeAt(0);
    for(var y = 0; y < mapSize.depth; y ++){
        for(var x = 0; x < mapSize.width; x ++){
            var index = y * (mapSize.width + 1) + x;
            var b;
            var p1= map.charCodeAt(index)-base;
            var p2= map.charCodeAt(index+1)-base;
            var p3= map.charCodeAt(index+1+mapSize.width + 1)-base;
            var p4= map.charCodeAt(index+mapSize.width + 1)-base;
            var type;
            if((p1 === p2 && p3 === p4) || (p1 === p4 && p2 === p3)){
                type = types.quad;
            }else if(p1 === p3){
                type = types.split1;
            }else if(p4 === p2){
                type = types.split2;
            }else{
                throw new RangeError("Map has badly formed block")
            }
            blocks.push(
                b = createBlock(
                    x,y,0,p1,p2,p3,p4,type
                )
            );
        }
    }
}
function drawMap(){
   for(var i = 0; i < blocks.length; i ++){
       var edge = 0;
       if(i % mapSize.width === mapSize.width- 1){
           edge = 1;
       }
       if(Math.floor(i / mapSize.width) === mapSize.width- 1){
           edge |= 2;
       }
       drawBlock(blocks[i],"black",1,edge);
   }
}
function drawBallShadow(ball){
    var i;
    var x,y,ix,iy;
    ctx.globalAlpha = 0.5;
    ctx.fillStyle = "black";
    ctx.beginPath();
    var first = 0;
    for(var i = 0; i < 1; i += 1/8){
        var ang = i * Math.PI * 2;
        x = ball.x + (ball.rad / projection.width ) * Math.cos(ang) * 0.7;
        y = ball.y + (ball.rad / projection.depth ) * Math.sin(ang) * 0.7;
        if(x < mapSize.width && x >= 0 && y < mapSize.depth && y > 0){
            ix = Math.floor(x + mapSize.width) % mapSize.width;
            iy = Math.floor(y + mapSize.depth) % mapSize.depth;
            var block = blocks[ix + iy * mapSize.width];
            var z = getHeight(block,x,y);
            projection.toScreen(x,y,z, pointArray);
            if(first === 0){
                first = 1;
                ctx.moveTo(pointArray[0],pointArray[1]);
            }else{
                ctx.lineTo(pointArray[0],pointArray[1]);
            }
        }
    }
    ctx.fill();
    ctx.globalAlpha = 1;

}

function drawBall(ball){
    projection.toScreen(ball.x, ball.y, ball.z, pointArray);
    ctx.fillStyle = ball.col;
    ctx.strokeStyle = "black";
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.arc(pointArray[0],pointArray[1],ball.rad,0,Math.PI * 2);
    ctx.stroke();
    ctx.fill();
    ctx.fillStyle = "white";
    ctx.beginPath();
    ctx.arc(pointArray[0]-ball.rad/2,pointArray[1]-ball.rad/2,ball.rad/4,0,Math.PI * 2);
    ctx.fill();
}
function updateBall(ball){
    // reset ball if out of bounds;
    if(ball.x > mapSize.width || ball.y > mapSize.depth || ball.x < 0 || ball.y < 0){
        ball.x = Math.random() * 3;
        ball.y = Math.random() * 3;
        // give random speed
        ball.dx = Math.random() * 0.01;
        ball.dy = Math.random() * 0.01;

    }
    // get the block under the ball
    var block = blocks[Math.floor(ball.x) + Math.floor(ball.y) * mapSize.width];
    // get the height of the black at the balls position
    ball.z = getHeight(block,ball.x,ball.y);
    // use the face normal to add velocity in the direction of the normal
    ball.dx += block.pNorm.x * 0.001;
    ball.dy += block.pNorm.y * 0.001;
    // move the ball up by the amount of its radius
    ball.z += ball.rad / projection.height;
    // draw the shadow and ball
    ball.x += ball.dx;
    ball.y += ball.dy;
    // get distance from camera;
    ball.dist = Math.hypot(ball.x - 20,ball.y - 20, ball.z - 20);
}
function renderBall(ball){
    drawBallShadow(ball);
    drawBall(ball);
}
function copyCanvas(canvas){
    var can = document.createElement("canvas");
    can.width = canvas.width;
    can.height = canvas.height;
    can.ctx = can.getContext("2d");
    can.ctx.drawImage(canvas,0,0);
    return can;
}
var map = `
    9988888789
    9888887678
    8888876567
    8888765678
    8777655567
    7666555456
    6555554456
    5444443456
    4433334567
    3332345678
`.replace(/\n| |\t/g,"");
var mapSize = {width : 9, depth : 9}; // one less than map width and depth
var blocks = [];
ctx.clearRect(0,0,canvas.width,canvas.height)
createMap();
drawMap();
var background = copyCanvas(canvas);
var w = canvas.width;
var h = canvas.height;
var cw = w / 2;  // center
var ch = h / 2;
var globalTime;  // global to this
var balls = [{
        x : -10,
        y : 0,
        z : 10,
        dx : 0,
        dy : 0,
        dz : 0,
        col : "red",
        rad : 10,
    },{
        x : -10,
        y : 0,
        z : 10,
        dx : 0,
        dy : 0,
        dz : 0,
        col : "Green",
        rad : 10,
    },{
        x : -10,
        y : 0,
        z : 10,
        dx : 0,
        dy : 0,
        dz : 0,
        col : "Blue",
        rad : 10,
    },{
        x : -10,
        y : 0,
        z : 10,
        dx : 0,
        dy : 0,
        dz : 0,
        col : "yellow",
        rad : 10,
    },{
        x : -10,
        y : 0,
        z : 10,
        dx : 0,
        dy : 0,
        dz : 0,
        col : "cyan",
        rad : 10,
    }
];

// main update function
function update(timer){
    globalTime = timer;
    ctx.setTransform(1,0,0,1,0,0); // reset transform
    ctx.globalAlpha = 1;           // reset alpha
    ctx.clearRect(0,0,w,h);
    ctx.drawImage(background,0,0);
    // get the block under the ball
    for(var i = 0; i < balls.length; i ++){
        updateBall(balls[i]);
    }
    balls.sort((a,b)=>b.dist - a.dist);
    for(var i = 0; i < balls.length; i ++){
        renderBall(balls[i]);
    }
    requestAnimationFrame(update);
}
requestAnimationFrame(update);