Canvas Rotating Star Field
Asked Answered
S

2

27

I'm taking the following approach to animate a star field across the screen, but I'm stuck for the next part.

JS

var c = document.getElementById('stars'),
    ctx = c.getContext("2d"),
    t = 0; // time

c.width = 300;
c.height = 300;

var w = c.width,
    h = c.height,
    z = c.height,
    v = Math.PI; // angle of vision

(function animate() {

    Math.seedrandom('bg');
    ctx.globalAlpha = 1;

    for (var i = 0; i <= 100; i++) {

        var x = Math.floor(Math.random() * w), // pos x
            y = Math.floor(Math.random() * h), // pos y
            r = Math.random()*2 + 1, // radius
            a = Math.random()*0.5 + 0.5, // alpha

            // linear
            d = (r*a),       // depth
            p = t*d;         // pixels per t

        x = x - p;       // movement
        x = x - w * Math.floor(x / w); // go around when x < 0

        (function draw(x,y) {
            var gradient = ctx.createRadialGradient(x, y, 0, x + r, y + r, r * 2);
            gradient.addColorStop(0, 'rgba(255, 255, 255, ' + a + ')');
            gradient.addColorStop(1, 'rgba(0, 0, 0, 0)');

            ctx.beginPath();
            ctx.arc(x, y, r, 0, 2*Math.PI);
            ctx.fillStyle = gradient;
            ctx.fill();

            return draw;

        })(x, y);

    }

    ctx.restore();
    t += 1;

    requestAnimationFrame(function() {
        ctx.clearRect(0, 0, c.width, c.height);
        animate();
    });
})();

HTML

<canvas id="stars"></canvas>

CSS

canvas {
    background: black;
}

JSFiddle

What it does right now is animate each star with a delta X that considers the opacity and size of the star, so the smallest ones appear to move slower.

Use p = t; to have all the stars moving at the same speed.

QUESTION

I'm looking for a clearly defined model where the velocities give the illusion of the stars rotating around the expectator, defined in terms of the center of the rotation cX, cY, and the angle of vision v which is what fraction of 2π can be seen (if the center of the circle is not the center of the screen, the radius should be at least the largest portion). I'm struggling to find a way that applies this cosine to the speed of star movements, even for a centered circle with a rotation of π.

These diagrams might further explain what I'm after:

Centered circle:

center of vision in x,y

Non-centered:

shifted center

Different angle of vision:

different angle of vision

I'm really lost as to how to move forwards. I already stretched myself a bit to get here. Can you please help me with some first steps?

Thanks


UPDATE

I have made some progress with this code:

        // linear
        d = (r*a)*z,   // depth
        v = (2*Math.PI)/w,
        p = Math.floor( d * Math.cos( t * v ) );     // pixels per t

    x = x + p;       // movement
    x = x - w * Math.floor(x / w); // go around when x < 0

JSFiddle

Where p is the x coordinate of a particle in uniform circular motion and v is the angular velocity, but this generates a pendulum effect. I am not sure how to change these equations to create the illusion that the observer is turning instead.


UPDATE 2:

Almost there. One user at the ##Math freenode channel was kind enough to suggest the following calculation:

        // linear
        d = (r*a),       // depth
        p = t*d;         // pixels per t

    x = x - p;       // movement
    x = x - w * Math.floor(x / w); // go around when x < 0

    x = (x / w) - 0.5;
    y = (y / h) - 0.5;

    y /= Math.cos(x);

    x = (x + 0.5) * w;
    y = (y + 0.5) * h;

JSFiddle

This achieves the effect visually, but does not follow a clearly defined model in terms of the variables (it just "hacks" the effect) so I cannot see a straightforward way to do different implementations (change the center, angle of vision). The real model might be very similar to this one.


UPDATE 3

Following from Iftah's response, I was able to use Sylvester to apply a rotation matrix to the stars, which need to be saved in an array first. Also each star's z coordinate is now determined and the radius r and opacity a are derived from it instead. The code is substantially different and lenghthier so I am not posting it, but it might be a step in the right direction. I cannot get this to rotate continuously yet. Using matrix operations on each frame seems costly in terms of performance.

JSFiddle

Sabulous answered 8/8, 2015 at 4:2 Comment(3)
Think of your starfield as the sky and think of your viewer looking out of their window at the stars. Make your starfield bigger than the viewport (bigger than the users window). Then, for example, as cY moves downward you would show more of the top and less of the bottom your starfield. You didn't mention cZ (like in your second illustration where the person views with their eyes pressed to the viewport). As cZ nears the viewport you show more of your starfield in all directions. The stars are so far away that their respective speeds need not change -- unless you're at warp speed!Delvecchio
It would be unnecessary to draw stars out of the canvas though. I think what we are looking for is p = f(d) that uses a cosine to mimic the change of x in a circle of section v. As you point out, though, there'z a cZ variable that I did not put for the first example, I'll add it.Sabulous
Correct, the canvas element is your viewport which shows a portion of your larger starfield (as if beyond the canvas "window"). You only need to display the portion of your starfield that is inside the canvas element.Delvecchio
H
13

Here's some pseudocode that does what you're talking about.

Make a bunch of stars not too far but not too close (via rejection sampling)
Set up a projection matrix (defines the camera frustum)
Each frame
    Compute our camera rotation angle
    Make a "view" matrix (repositions the stars to be relative to our view)
    Compose the view and projection matrix into the view-projection matrix
    For each star
        Apply the view-projection matrix to give screen star coordinates
        If the star is behind the camera skip it
        Do some math to give the star a nice seeming 'size'
        Scale the star coordinate to the canvas
        Draw the star with its canvas coordinate and size

I've made an implementation of the above. It uses the gl-matrix Javascript library to handle some of the matrix math. It's good stuff. (Fiddle for this is here, or see below.)

var c = document.getElementById('c');
var n = c.getContext('2d');

// View matrix, defines where you're looking
var viewMtx = mat4.create();

// Projection matrix, defines how the view maps onto the screen
var projMtx = mat4.create();

// Adapted from https://mcmap.net/q/28453/-how-to-build-perspective-projection-matrix-no-api
function ComputeProjMtx(field_of_view, aspect_ratio, near_dist, far_dist, left_handed) {
    // We'll assume input parameters are sane.
    field_of_view = field_of_view * Math.PI / 180.0; // Convert degrees to radians
    var frustum_depth = far_dist - near_dist;
    var one_over_depth = 1 / frustum_depth;
    var e11 = 1.0 / Math.tan(0.5 * field_of_view);
    var e00 = (left_handed ? 1 : -1) * e11 / aspect_ratio;
    var e22 = far_dist * one_over_depth;
    var e32 = (-far_dist * near_dist) * one_over_depth;
    return [
        e00, 0, 0, 0,
        0, e11, 0, 0,
        0, 0, e22, e32,
        0, 0, 1, 0
    ];
}

// Make a view matrix with a simple rotation about the Y axis (up-down axis)
function ComputeViewMtx(angle) {
    angle = angle * Math.PI / 180.0; // Convert degrees to radians
    return [
        Math.cos(angle), 0, Math.sin(angle), 0,
        0, 1, 0, 0,
        -Math.sin(angle), 0, Math.cos(angle), 0,
        0, 0, 0, 1
    ];
}

projMtx = ComputeProjMtx(70, c.width / c.height, 1, 200, true);

var angle = 0;

var viewProjMtx = mat4.create();

var minDist = 100;
var maxDist = 1000;

function Star() {
    var d = 0;
    do {
        // Create random points in a cube.. but not too close.
        this.x = Math.random() * maxDist - (maxDist / 2);
        this.y = Math.random() * maxDist - (maxDist / 2);
        this.z = Math.random() * maxDist - (maxDist / 2);
        var d = this.x * this.x +
                this.y * this.y +
                this.z * this.z;
    } while (
         d > maxDist * maxDist / 4 || d < minDist * minDist
    );
    this.dist = Math.sqrt(d);
}

Star.prototype.AsVector = function() {
    return [this.x, this.y, this.z, 1];
}

var stars = [];
for (var i = 0; i < 5000; i++) stars.push(new Star());

var lastLoop = Date.now();

function loop() {
    
    var now = Date.now();
    var dt = (now - lastLoop) / 1000.0;
    lastLoop = now;
    
    angle += 30.0 * dt;

    viewMtx = ComputeViewMtx(angle);
    
    //console.log('---');
    //console.log(projMtx);
    //console.log(viewMtx);
    
    mat4.multiply(viewProjMtx, projMtx, viewMtx);
    //console.log(viewProjMtx);
    
    n.beginPath();
    n.rect(0, 0, c.width, c.height);
    n.closePath();
    n.fillStyle = '#000';
    n.fill();
    
    n.fillStyle = '#fff';
    
    var v = vec4.create();
    for (var i = 0; i < stars.length; i++) {
        var star = stars[i];
        vec4.transformMat4(v, star.AsVector(), viewProjMtx);
        v[0] /= v[3];
        v[1] /= v[3];
        v[2] /= v[3];
        //v[3] /= v[3];
        
        if (v[3] < 0) continue;

        var x = (v[0] * 0.5 + 0.5) * c.width;
        var y = (v[1] * 0.5 + 0.5) * c.height;
        
        // Compute a visual size...
        // This assumes all stars are the same size.
        // It also doesn't scale with canvas size well -- we'd have to take more into account.
        var s = 300 / star.dist;
        
        
        n.beginPath();
        n.arc(x, y, s, 0, Math.PI * 2);
        //n.rect(x, y, s, s);
        n.closePath();
        n.fill();
    }
    
    window.requestAnimationFrame(loop);
}

loop();
<script src="https://cdnjs.cloudflare.com/ajax/libs/gl-matrix/2.3.1/gl-matrix-min.js"></script>
<canvas id="c" width="500" height="500"></canvas>

Some links:

Update

Here's another version that has keyboard controls. Kinda fun. You can see the difference between rotating and parallax from strafing. Works best full page. (Fiddle for this is here or see below.)

var c = document.getElementById('c');
var n = c.getContext('2d');

// View matrix, defines where you're looking
var viewMtx = mat4.create();

// Projection matrix, defines how the view maps onto the screen
var projMtx = mat4.create();

// Adapted from https://mcmap.net/q/28453/-how-to-build-perspective-projection-matrix-no-api
function ComputeProjMtx(field_of_view, aspect_ratio, near_dist, far_dist, left_handed) {
    // We'll assume input parameters are sane.
    field_of_view = field_of_view * Math.PI / 180.0; // Convert degrees to radians
    var frustum_depth = far_dist - near_dist;
    var one_over_depth = 1 / frustum_depth;
    var e11 = 1.0 / Math.tan(0.5 * field_of_view);
    var e00 = (left_handed ? 1 : -1) * e11 / aspect_ratio;
    var e22 = far_dist * one_over_depth;
    var e32 = (-far_dist * near_dist) * one_over_depth;
    return [
        e00, 0, 0, 0,
        0, e11, 0, 0,
        0, 0, e22, e32,
        0, 0, 1, 0
    ];
}

// Make a view matrix with a simple rotation about the Y axis (up-down axis)
function ComputeViewMtx(angle) {
    angle = angle * Math.PI / 180.0; // Convert degrees to radians
    return [
        Math.cos(angle), 0, Math.sin(angle), 0,
        0, 1, 0, 0,
        -Math.sin(angle), 0, Math.cos(angle), 0,
        0, 0, -250, 1
    ];
}

projMtx = ComputeProjMtx(70, c.width / c.height, 1, 200, true);

var angle = 0;

var viewProjMtx = mat4.create();

var minDist = 100;
var maxDist = 1000;

function Star() {
    var d = 0;
    do {
        // Create random points in a cube.. but not too close.
        this.x = Math.random() * maxDist - (maxDist / 2);
        this.y = Math.random() * maxDist - (maxDist / 2);
        this.z = Math.random() * maxDist - (maxDist / 2);
        var d = this.x * this.x +
                this.y * this.y +
                this.z * this.z;
    } while (
         d > maxDist * maxDist / 4 || d < minDist * minDist
    );
    this.dist = 100;
}

Star.prototype.AsVector = function() {
    return [this.x, this.y, this.z, 1];
}

var stars = [];
for (var i = 0; i < 5000; i++) stars.push(new Star());

var lastLoop = Date.now();


var dir = {
    up: 0,
    down: 1,
    left: 2,
    right: 3
};

var dirStates = [false, false, false, false];
var shiftKey = false;

var moveSpeed = 100.0;
var turnSpeed = 1.0;

function loop() {
    var now = Date.now();
    var dt = (now - lastLoop) / 1000.0;
    lastLoop = now;
    
    angle += 30.0 * dt;

    //viewMtx = ComputeViewMtx(angle);
    var tf = mat4.create();
    if (dirStates[dir.up]) mat4.translate(tf, tf, [0, 0, moveSpeed * dt]);
    if (dirStates[dir.down]) mat4.translate(tf, tf, [0, 0, -moveSpeed * dt]);
    if (dirStates[dir.left])
        if (shiftKey) mat4.rotate(tf, tf, -turnSpeed * dt, [0, 1, 0]);
        else mat4.translate(tf, tf, [moveSpeed * dt, 0, 0]);
    if (dirStates[dir.right])
        if (shiftKey) mat4.rotate(tf, tf, turnSpeed * dt, [0, 1, 0]);
        else mat4.translate(tf, tf, [-moveSpeed * dt, 0, 0]);
    mat4.multiply(viewMtx, tf, viewMtx);
    
    //console.log('---');
    //console.log(projMtx);
    //console.log(viewMtx);
    
    mat4.multiply(viewProjMtx, projMtx, viewMtx);
    //console.log(viewProjMtx);
    
    n.beginPath();
    n.rect(0, 0, c.width, c.height);
    n.closePath();
    n.fillStyle = '#000';
    n.fill();
    
    n.fillStyle = '#fff';
    
    var v = vec4.create();
    for (var i = 0; i < stars.length; i++) {
        var star = stars[i];
        vec4.transformMat4(v, star.AsVector(), viewProjMtx);
        
        if (v[3] < 0) continue;
        
        var d = Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]);
        
        v[0] /= v[3];
        v[1] /= v[3];
        v[2] /= v[3];
        //v[3] /= v[3];
        

        var x = (v[0] * 0.5 + 0.5) * c.width;
        var y = (v[1] * 0.5 + 0.5) * c.height;
        
        // Compute a visual size...
        // This assumes all stars are the same size.
        // It also doesn't scale with canvas size well -- we'd have to take more into account.
        var s = 300 / d;
        
        
        n.beginPath();
        n.arc(x, y, s, 0, Math.PI * 2);
        //n.rect(x, y, s, s);
        n.closePath();
        n.fill();
    }
    
    window.requestAnimationFrame(loop);
}

loop();

function keyToDir(evt) {
    var d = -1;
    if (evt.keyCode === 38) d = dir.up
    else if (evt.keyCode === 37) d = dir.left;
    else if (evt.keyCode === 39) d = dir.right;
    else if (evt.keyCode === 40) d = dir.down;
    return d;
}

window.onkeydown = function(evt) {
    var d = keyToDir(evt);
    if (d >= 0) dirStates[d] = true;
    if (evt.keyCode === 16) shiftKey = true;
}

window.onkeyup = function(evt) {
    var d = keyToDir(evt);
    if (d >= 0) dirStates[d] = false;
    if (evt.keyCode === 16) shiftKey = false;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/gl-matrix/2.3.1/gl-matrix-min.js"></script>
<div>Click in this pane. Use up/down/left/right, hold shift + left/right to rotate.</div>
<canvas id="c" width="500" height="500"></canvas>

Update 2

Alain Jacomet Forte asked:

What is your recommended method of creating general purpose 3d and if you would recommend working at the matrices level or not, specifically perhaps to this particular scenario.

Regarding matrices: If you're writing an engine from scratch on any platform, then you're unavoidably going to end up working with matrices since they help generalize the basic 3D mathematics. Even if you use OpenGL/WebGL or Direct3D you're still going to end up making a view and projection matrix and additional matrices for more sophisticated purposes. (Handling normal maps, aligning world objects, skinning, etc...)

Regarding a method of creating general purpose 3d... Don't. It will run slow, and it won't be performant without a lot of work. Rely on a hardware-accelerated library to do the heavy lifting. Creating limited 3D engines for specific projects is fun and instructive (e.g. I want a cool animation on my webpage), but when it comes to putting the pixels on the screen for anything serious, you want hardware to handle that as much as you can for performance purposes.

Sadly, the web has no great standard for that yet, but it is coming in WebGL -- learn WebGL, use WebGL. It runs great and works well when it's supported. (You can, however, get away with an awful lot just using CSS 3D transforms and Javascript.)

If you're doing desktop programming, I highly recommend OpenGL via SDL (I'm not sold on SFML yet) -- it's cross-platform and well supported.

If you're programming mobile phones, OpenGL ES is pretty much your only choice (other than a dog-slow software renderer).

If you want to get stuff done rather than writing your own engine from scratch, the defacto for the web is Three.js (which I find effective but mediocre). If you want a full game engine, there's some free options these days, the main commercial ones being Unity and Unreal. Irrlicht has been around a long time -- never had a chance to use it, though, but I hear it's good.

But if you want to make all the 3D stuff from scratch... I always found how the software renderer in Quake was made a pretty good case study. Some of that can be found here.

Howdah answered 10/8, 2015 at 23:44 Comment(11)
This is basically what Iftah is proposing.. It's the standard way to do things "3D".. Set up a world space, put items in that space, and figure out where they should appear on the screen if there's a virtual camera in that space.Howdah
Incidentally, this code is generic enough to view the sphere of stars from any vantage -- just modify the view matrix in the proper way to make it happen. Easy way is to use gl-matrix's "look at" function to set the view matrix.Howdah
Also, gl-matrix can be used to make a projection and view matrix more easily -- but I thought the math might be useful to see if you didn't want to use gl-matrix.Howdah
Thank you, a lot to read over here. I was wondering if you could add to your example how to use the z value of each star in the calculation of velocities. In theory, stars should move at different speeds depending on their distance.Sabulous
"In theory, stars should move at different speeds depending on their distance." -- Maybe, maybe not. If the stars are rotating about the Y-axis and the camera is centered on the Y-axis, this will look the same as the stars staying still and the camera rotating. In this case, no stars would appear to move faster or slower than each other -- it's like rotating your head. If you want to achieve something that looks more like parallax, but with a higher degree of realism, then the stars need to move differently.Howdah
To elaborate, though, if you want the classic parallax effect, apparent speed of movement is inversely related to an object's distance. That is to say, due to homogeneous coordinates (the usual way of modelling 3D phenomenon) the position of an object at (x,y,z) appears to be on a canvas at (x/z, y/z) -- so if it's moving at dx, dy then you can expect the visual appearance to be movement by (dx/z, dy/z) -- this is conceptually rough but more or less true.Howdah
Could you please edit the fiddle's code into your answer so that it can be archived for all time and the script run here on SO, inline, without fiddle rotEldredge
@Paul: Done, thanks for the tip! It's too bad it's so spammy, though -- is there a way to make the section collapsed by default?Howdah
Thank you again for this wonderful answer, I've been digging more and more into this. I have a small petition, if you could add to the answer what is your recommended method of creating general purpose 3d and if you would recommend working at the matrices level or not, specifically perhaps to this particular scenario.Sabulous
@Alain, I've updated with kind of a meandering answer... I wasn't sure of the scope of your question, so it's very broad -- please let me know if you'd like something more specific.Howdah
@Howdah You had asked how to make a code section collapse by default - if you are still interested in doing that, you can accomplish it by changing the snippet markdown in your post from <!-- begin snippet: js hide: false --> to <!-- begin snippet: js hide: true -->.Ales
M
2

You are resetting the stars 2d position each frame, then moving the stars (depending on how much time and speed of each star) - this is a bad way to achieve your goal. As you discovered, it gets very complex when you try to extend this solution to more scenarios.

A better way would be to set the stars 3d location only once (at initialization) then move a "camera" each frame (depending on time). When you want to render the 2d image you then calculate the stars location on screen. The location on screen depends on the stars 3d location and the current camera location. This will allow you to move the camera (in any direction), rotate the camera (to any angle) and render the correct stars position AND keep your sanity.

Meatus answered 9/8, 2015 at 14:8 Comment(7)
Could provide some code example of what you are proposing? That seems to be a very big change. Furthermore, extrapolating for that model, we could find equations that fit mine, reasonably, no? -- I fail to see how this would be different in terms of code. Seems to be different only in concept. ThanksSabulous
I guess what I am suggesting is to build a small 3d engine, it requires some (very little) linear algebra to do elegantly.Meatus
That's an interesting approach. It would be great if you could elaborate on that.Sabulous
Here is what I did from your comments using numeric.js, and getting the dot product of the [x,y,z] vectors and a rotation matrix around the Y-axis: jsfiddle.net/u4Lq8fgd, but I'm not great with linear algebra, they are rotating but moving away! Can you help?Sabulous
Sorry busy now... I will have free time later, in ~4 hours, will try thenMeatus
I have updated my last effort with matrices in the questionSabulous
sorry was busy, wanted to try it now but @Howdah got it done - I suggest you accept his excellent answerMeatus

© 2022 - 2024 — McMap. All rights reserved.