I need to know how to do per object transforms (for animation) with a single drawArrays call - in js, in the shader? And how?
I have a game engine I am intending to use to prototype iOS and Android games with. I started from a webgl lesson and it has been coming along nicely. Get the source here:GitHub - johnnyscott/soft-voxels: Attempt at robust OpenGl ES 2.0 game engine prototype for refactor for Android and iPhone/iPad game development. Runs in webGl enabled browser (chrome, safari, firefox but firefox sucks like everybody knows when it comes to performance) (I’ll also copy pasta it below)
(Use Chrome)
See it here (until my dynamic IP shuffles):
http://208.126.167.117/webgl/soft-voxels/
You fly around with WASD and look around with the mouse. Also you can hit ~ for the console and type ? for a very sparse list of commands ;D.
At first I was doing a call to drawArrays for every object in the scene per frame and found around 100-1000x performance increase by instead doing a single drawArrays call per frame for all objects. I went from having around 2000 objects at 60fps to easily supporting about 100,000+ at 60fps (actually found no limit)
Clearly a single call to drawArrays is the way to go for performance reasons.
I accomplished this by creating a scene graph ‘class’ that builds a vertex array once before the render loop begins (scene.rebuildBuffers()). This function \ traverses the graph recursively and adds each node’s world coords to its local coords and stuffs the result into the end of this massive array (along with texture coords into another array).
The issue is
- I am only doing translation (no rotation or scale) - should have a translation matrix per object probably
- I have no idea how I ought to do the transformations per object now. Right now rebuilding the buffers for 40k objects takes a couple hundred ms and I can’t do that once per frame and stay at 60fps.
I thought ‘perhaps I only update the objects that have changed’ but still this requires some kind of monkey tricks and it’s in javascript so it’ll be slow doing matrix multiplication and then updating the vertex positions at some offset.
My other thought was is there some way to maybe have another uniform or something in my shader that holds the transform matrices per object and then update that for the object that has changed and do the transform matrix multiplication in the vertex shader.
I am pasting code below or you can see it at github it below so you can see.
THANKS SO MUCH FOR YOUR HELP!!!
(ps: the code is messy still have unused functions from the example I’m cleaning as I go)
<!DOCTYPE html>
<html>
<head>
<title>Soft Voxels</title>
<meta http-equiv=“content-type” content=“text/html; charset=ISO-8859-1”>
<style>
html,body{ overflow:hidden;background:black; cursor:text;}
canvas {
position: absolute;
top: 0;
left: 0;
background:black;
}
.console{
position:absolute;
z-index:1000;
top:-400px;
left:0;
background:transparent;
height:400px;
width:33%;
opacity:0.90;
}
.console span{
display:block;
position:absolute;
top:0px;
bottom:32px;
left:0;
right:0;
width:auto;
height:auto;
background:#343942;
font-family:‘Dina’, ‘Courier New’, ‘Courier’, monospace;
font-size:15px;
color:white;
overflow-y:scroll;
overflow-x:hidden;
}
.console input{
position:absolute;
bottom:3px;
width:auto;
left:0;
right:0;
height:20px;
}
</style>
<script src=“http://ajax.googleapis.com/ajax/libs/jquery/1.6.2/jquery.min.js”></script>
<script type=“text/javascript” src=“glMatrix-0.9.5.min.js”></script>
<script type=“text/javascript” src=“webgl-utils.js”></script>
/* star shader
<script id=“shader-fs” type=“x-shader/x-fragment”>
#ifdef GL_ES
precision highp float;
#endif
varying vec2 vTextureCoord;
uniform sampler2D uSampler;
void main(void) {
vec4 textureColor = texture2D(uSampler, vec2(vTextureCoord.s, vTextureCoord.t));
if ( textureColor.a < 0.4 ) discard ;
gl_FragColor = textureColor;// * vec4(uColor, 1.0);
}
</script>
<script id=“shader-vs” type=“x-shader/x-vertex”>
attribute vec3 aVertexPosition;
attribute vec2 aTextureCoord;
uniform mat4 uMVMatrix;
uniform mat4 uPMatrix;
varying vec2 vTextureCoord;
void main(void) {
gl_Position = uPMatrix * uMVMatrix * vec4(aVertexPosition, 1.0);
vTextureCoord = aTextureCoord;
}
</script>
<script type=“text/javascript”>
var bL = true; // logging on or off?
var aCommandLog = []; // stack of commands entered
var iCommandLogIndex = -1; // position in command stack
// console comamnd names
var CC_CLEAR = 'clear';
var CC_HELP = '?';
var LOOK_ANGLE_RANGE = 540;
var VIEW_VOLUME_SIZE = 1000;
var KEY_W = 87;
var KEY_A = 65;
var KEY_S = 83;
var KEY_D = 68;
var KEY_TILDE = 192;
var KEY_ENTER = 13;
var KEY_UP = 38;
var KEY_DOWN = 40;
var gl;
var fFOV = 45.0 // field of view
var consoleLineNumber = 0;
var zoom = -15;
var tilt = 10;
var spin = 0;
var pan = 0;
var runSpeed = 0;
var runStep = 0.001;
var strafeSpeed = 0;
var pX = 0;
var pY =0;
var pZ = 0;
var friction = .0007;
// fit canvas to window
function fit(){ $('canvas').css({width:$(window).width()+'px'});}
function handleKeys()
{
// RUN FORWARD
if (currentlyPressedKeys[KEY_W]) { //w
// Page Down
zoom += 0.1;
runSpeed += runStep;
}
// STRAFE LEFT
if (currentlyPressedKeys[KEY_A]) { //a
// Up cursor key
pan += 0.1;
strafeSpeed +=runStep;
}
// RUN BACKWARDS
if (currentlyPressedKeys[KEY_S]) { //s
// Page Up
zoom -= 0.1;
runSpeed -=runStep;
}
// STRAFE RIGHT
if (currentlyPressedKeys[KEY_D]) { //d
// Down cursor key
pan -= 0.1;
strafeSpeed -= runStep;
}
// TOGGLE CONSOLE
if (currentlyPressedKeys[KEY_TILDE] ) { // ~ `
// Down cursor key
toggleConsole();
}
}
// browser console
function lC(s){ if(bL){console.log(s);}else{}}
// engine console
function l(s)
{
if(bL){
consoleLineNumber++; var oConsole = getConsoleLog();
oConsole.html(
oConsole.html()
+ consoleLineNumber
+ ''
+ s
+ '
’
);
oConsole[0].scrollTop = oConsole[0].scrollHeight;
}else{}
}
function getConsoleLog(){ return getConsole().find(‘span’); }
function getConsole(){ return ('.console');}
function getConsoleInput(){ return (‘.console input’);}
function setConsoleInput(sCmd){ getConsoleInput().val(sCmd); }
function clearConsoleInput(){ getConsoleInput().val(‘’);}
function clearConsole(){ getConsoleLog().html(‘’); }
var bInConsoleToggle = false;
function toggleConsole()
{
if(bInConsoleToggle){return false;}else{}
bInConsoleToggle = true;
var o = getConsole();
var iTop = 0;
if(o.offset().top > -1){
iTop = -o.height();
getConsoleInput().blur();
}else{ getConsoleInput().focus(); }
o.animate(
{
top: iTop + 'px'
}
,150
,'swing'
,function(){ clearConsoleInput(); bInConsoleToggle = false; }
);
}
function consoleSaveCommand(sCmd)
{
aCommandLog.push(sCmd);
iCommandLogIndex = aCommandLog.length -1;
}
// run console command and log command and output
function consoleCommand(sCmd)
{
consoleSaveCommand(sCmd);
clearConsoleInput();
var sOutput = sCmd;
switch(sCmd){
case CC_CLEAR: CC_clear(); return /* special case - don't log clear */; break;
case CC_HELP: sOutput += CC_help(); break;
default: sOutput += CC_bad();
}
l(sOutput);
}
function consoleInputHandler(e)
{
var iKeyCode = e.keyCode;
if(iKeyCode == KEY_ENTER){
getConsole();
consoleCommand(e.srcElement.value);
}else{}
if(iKeyCode == KEY_UP){ // backup command
setConsoleInput(aCommandLog[iCommandLogIndex]);
iCommandLogIndex--; if(iCommsdddddddddandLogIndex < 0 ){iCommandLogIndex = 0; }else{}
}
if(iKeyCode == KEY_DOWN){ // next command
setConsoleInput(aCommandLog[iCommandLogIndex]);
iCommandLogIndex++; if(iCommandLogIndex >aCommandLog.length-1){iCommandLogIndex = aCommandLog.length-1; }else{}
}
}
// actual commands for engine console
function CC_bad(){ return '
command doesn't exist or command malformed’; }
function CC_clear(){ clearConsole(); return ‘’; }
function CC_help(){ return ’
Commands
-
clear - clear console
-
? - this help message’; }
function initGL(canvas) {
try {
gl = canvas.getContext(“experimental-webgl”);
gl.viewportWidth = canvas.width;
gl.viewportHeight = canvas.height;// perspective mat4.perspective(fFOV, gl.viewportWidth / gl.viewportHeight, 0.1, VIEW_VOLUME_SIZE, pMatrix); // when we want transparency we can do the following: // gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); // gl.enable(gl.BLEND); // we moved this so we don't do it every frame gl.enable (gl.DEPTH_TEST); } catch (e) {} if (!gl) { alert("Could not initialise WebGL: try get.webgl.org"); }
}
function getShader(gl, id) {
var shaderScript = document.getElementById(id);
if (!shaderScript) {
return null;
}var str = ""; var k = shaderScript.firstChild; while (k) { if (k.nodeType == 3) { str += k.textContent; } k = k.nextSibling; } var shader; if (shaderScript.type == "x-shader/x-fragment") { shader = gl.createShader(gl.FRAGMENT_SHADER); } else if (shaderScript.type == "x-shader/x-vertex") { shader = gl.createShader(gl.VERTEX_SHADER); } else { return null; } gl.shaderSource(shader, str); gl.compileShader(shader); if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { alert(gl.getShaderInfoLog(shader)); return null; } return shader;
}
var shaderProgram;
function initShaders() {
var fragmentShader = getShader(gl, “shader-fs”);
var vertexShader = getShader(gl, “shader-vs”);shaderProgram = gl.createProgram(); gl.attachShader(shaderProgram, vertexShader); gl.attachShader(shaderProgram, fragmentShader); gl.linkProgram(shaderProgram); if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS)) { alert("Could not initialise shaders"); } gl.useProgram(shaderProgram); shaderProgram.vertexPositionAttribute = gl.getAttribLocation(shaderProgram, "aVertexPosition"); // gets an integer, on inspection this is 0 gl.enableVertexAttribArray(shaderProgram.vertexPositionAttribute); shaderProgram.textureCoordAttribute = gl.getAttribLocation(shaderProgram, "aTextureCoord"); // gets an integer, on inspection this is 1 gl.enableVertexAttribArray(shaderProgram.textureCoordAttribute); shaderProgram.pMatrixUniform = gl.getUniformLocation(shaderProgram, "uPMatrix"); // projection matrix -> clipping and field of view, perspective shaderProgram.mvMatrixUniform = gl.getUniformLocation(shaderProgram, "uMVMatrix"); // modelview matrix -> puts camera in scene shaderProgram.samplerUniform = gl.getUniformLocation(shaderProgram, "uSampler");
}
function handleLoadedTexture(texture) {
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, texture.image);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_NEAREST);
gl.generateMipmap(gl.TEXTURE_2D);gl.bindTexture(gl.TEXTURE_2D, null);
}
var starTexture;
function initTexture() {
starTexture = gl.createTexture();
starTexture.image = new Image();
starTexture.image.onload = function () {
handleLoadedTexture(starTexture)
}starTexture.image.src = "grass-billboard.png";
}
var mvMatrix = mat4.create();
var mvMatrixStack = [];
var pMatrix = mat4.create();function mvPushMatrix() {
var copy = mat4.create();
mat4.set(mvMatrix, copy);
mvMatrixStack.push(copy);
}function mvPopMatrix() {
if (mvMatrixStack.length == 0) {
throw “Invalid popMatrix!”;
}
mvMatrix = mvMatrixStack.pop();
}function setMatrixUniforms() {
gl.uniformMatrix4fv(shaderProgram.pMatrixUniform, false, pMatrix);
gl.uniformMatrix4fv(shaderProgram.mvMatrixUniform, false, mvMatrix);
}function degToRad(degrees) {
return degrees * Math.PI / 180;
}var currentlyPressedKeys = {};
function handleKeyDown(event) {
currentlyPressedKeys[event.keyCode] = true;
}function handleKeyUp(event) {
currentlyPressedKeys[event.keyCode] = false;
}var starVertexPositionBuffer;
var starVertexTextureCoordBuffer;function initBuffers() {
starVertexPositionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, starVertexPositionBuffer); // gives us a pointer into a storevertices = scene.aVertexPositions; gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW); // actually allocates stoe and pushes data up starVertexPositionBuffer.itemSize = 3; starVertexPositionBuffer.numItems = vertices.length / starVertexPositionBuffer.itemSize; starVertexTextureCoordBuffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, starVertexTextureCoordBuffer); var textureCoords = scene.aTextureCoords; gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(textureCoords), gl.STATIC_DRAW); starVertexTextureCoordBuffer.itemSize = 2; starVertexTextureCoordBuffer.numItems = textureCoords.length/starVertexTextureCoordBuffer.itemSize; return;
}
function drawStar() {
// gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, starTexture);
gl.uniform1i(shaderProgram.samplerUniform, 0);gl.bindBuffer(gl.ARRAY_BUFFER, starVertexTextureCoordBuffer); gl.vertexAttribPointer(shaderProgram.textureCoordAttribute, starVertexTextureCoordBuffer.itemSize, gl.FLOAT, false, 0, 0); gl.bindBuffer(gl.ARRAY_BUFFER, starVertexPositionBuffer); gl.vertexAttribPointer(shaderProgram.vertexPositionAttribute, starVertexPositionBuffer.itemSize, gl.FLOAT, false, 0, 0); setMatrixUniforms(); gl.drawArrays(gl.TRIANGLES, 0, starVertexPositionBuffer.numItems);
}
function Star(startingDistance, rotationSpeed, position) {
this[‘x’] = position[0];
this[‘y’] = position[1];
this[‘z’] = position[2];this.angle = 0; this.dist = startingDistance; this.rotationSpeed = rotationSpeed;
}
Star.prototype.draw = function () {
mvPushMatrix();// Move to the star's positionw mat4.translate(mvMatrix, [this.x, this.y, this.z]); // Draw the star in its main color drawStar() mvPopMatrix();
};
var effectiveFPMS = 60 / 1000;
Star.prototype.animate = function (elapsedTime) {};
var stars = [];
// random half negative
function rndHN(iRange){
return (Math.random() * iRange) - iRange/2;
}var scene = null;
// star constructor: function Star(startingDistance, rotationSpeed) {
function initWorldObjects() {
scene = new sceneGraph();
var numBushes = 38530;
for (var i=0; i < numBushes; i++) {
scene.addChild(
new sceneNode(
new obj_quad({ position:
[rndHN(133)
,rndHN(1.1)
,rndHN(203)]
})
)
, 0);} var i = new Date().getTime(); scene.rebuildBuffers(); // generate nice arrays of vertices // array concat is slow that's the problem traversing only takes like 4 ms console.log(new Date().getTime() - i);
}
function drawScene() {
// is this needed? I commented it out and behavior is the same ??
//gl.viewport(0, 0, gl.viewportWidth, gl.viewportHeight);gl.clear(gl.COLOR_BUFFER_BIT ); // THIS IS DONE IN INIT NOW since it need only be done once // mat4.perspective(fFOV, gl.viewportWidth / gl.viewportHeight, 0.1, VIEW_VOLUME_SIZE, pMatrix); mat4.identity(mvMatrix); mat4.rotate(mvMatrix, degToRad(oMouse.angleY), [1.0, 0.0, 0.0]); mat4.rotate(mvMatrix, degToRad(oMouse.angleX), [0.0, 1.0, 0.0]); mat4.translate(mvMatrix, [pX, pY, pZ]); mvPushMatrix(); // Move to the star's positionw // mat4.translate(mvMatrix, [0, 0, 0]); // Draw the star in its main color gl.bindTexture(gl.TEXTURE_2D, starTexture); gl.uniform1i(shaderProgram.samplerUniform, 0); gl.bindBuffer(gl.ARRAY_BUFFER, starVertexTextureCoordBuffer); gl.vertexAttribPointer(shaderProgram.textureCoordAttribute, starVertexTextureCoordBuffer.itemSize, gl.FLOAT, false, 0, 0); gl.bindBuffer(gl.ARRAY_BUFFER, starVertexPositionBuffer); gl.vertexAttribPointer(shaderProgram.vertexPositionAttribute, starVertexPositionBuffer.itemSize, gl.FLOAT, false, 0, 0); setMatrixUniforms(); gl.drawArrays(gl.TRIANGLES, 0, starVertexPositionBuffer.numItems); mvPopMatrix();
}
var lastTime = 0;
function animate() {
var timeNow = new Date().getTime();
if (lastTime != 0) {
var elapsed = timeNow - lastTime;// apply friction to speeds if( runSpeed < 0){ runSpeed += friction; if(runSpeed > 0) {runSpeed=0;}else{} }else{ runSpeed -= friction; if(runSpeed < 0) {runSpeed=0;}else{} } // apply friction to speeds if( strafeSpeed < 0){ strafeSpeed += friction; if(strafeSpeed > 0) {strafeSpeed=0;}else{} }else{ strafeSpeed -= friction; if(strafeSpeed < 0) {strafeSpeed=0;}else{} } pX += Math.cos(degToRad(90+oMouse.angleX)) * runSpeed * elapsed; pZ += Math.sin(degToRad(90+oMouse.angleX)) * runSpeed * elapsed; pX += Math.cos(degToRad(oMouse.angleX)) * strafeSpeed * elapsed; pZ += Math.sin(degToRad(oMouse.angleX)) * strafeSpeed * elapsed; pY += Math.sin(degToRad(oMouse.angleY)) * runSpeed * elapsed; } lastTime = timeNow;
}
function tick() {
requestAnimFrame(tick);
handleKeys();
drawScene();
animate();
}function webGLStart() {
var canvas = document.getElementById(“lesson09-canvas”);
initGL(canvas);
initShaders();
initWorldObjects();
initBuffers();
initTexture();gl.clearColor(0.0, 0.0, 0.0, 1.0); document.onkeydown = handleKeyDown; document.onkeyup = handleKeyUp; tick();
}
var oMouse = {x:0,y:0,angleY:0.0,angleX:0.0}
function mouseMoveHandler(e)
{
oMouse.x = e.pageX;
oMouse.y = e.pageY;// calculate angle as percentage of window height/width
var iH = (window).height(); var iW = (window).width();
var x = oMouse.x;
var y = oMouse.y;var oldAX = oMouse.angleX;
var oldAY = oMouse.angleY;oMouse.angleX = (x/iW) * LOOK_ANGLE_RANGE;
oMouse.angleY = (y/iH) * LOOK_ANGLE_RANGE;
oMouse.angleY -= LOOK_ANGLE_RANGE/2;
oMouse.angleX -= LOOK_ANGLE_RANGE/2;}
$(document).ready(
function()
{fit(); $(window).resize( fit ); // fit canvas to window handler $(document).mousemove( mouseMoveHandler ); // mouse move handler getConsoleInput().keyup( consoleInputHandler ); // console command handler
}
);
// SCENE GRAPH
// BASE OBJECT //////////////////////////////
var obj_base = {};
obj_base.aDefaultProperties = {
position: [0.0, 0.0, 0.0] // world position
};
// QUAD OBJECT /////////////////////////////
function obj_quad(aProperties)
{
// get defaults from base object, overwrite with arguments
for(var k in obj_base.aDefaultProperties){ this[k] = obj_base.aDefaultProperties[k]; }
for(var k in aProperties){ this[k] = aProperties[k]; }
this.aVertexPositions =
[-1.0, -1.0, 0.0,
1.0, -1.0, 0.0,
-1.0, 1.0, 0.0,
-1.0, 1.0, 0.0,
1.0, -1.0, 0.0,
1.0, 1.0, 0.0
];
this.iTriangleCount = this.aVertexPositions.length / 9;
this.aTextureCoords =
[0.0, 0.0,
1.0, 0.0,
0.0, 1.0,
0.0, 1.0,
1.0, 0.0,
1.0, 1.0
];
}
obj_quad.prototype.getVertexPositions = function() // applies world position to local vertex positions
{
var aReturnVertexArray = [];
var a = this.aVertexPositions;
var p = this.position;
for(var i = 0; i < this.iTriangleCount; i++){
aReturnVertexArray = aReturnVertexArray.concat(
[ a[(i*9)] + p[0], a[(i*9)+1] + p[1], a[(i*9)+2] + p[2] ] // vertex 1
,[ a[(i*9)+3] + p[0], a[(i*9)+4] + p[1], a[(i*9)+5] + p[2] ] // vertex 2
,[ a[(i*9)+6] + p[0], a[(i*9)+7] + p[1], a[(i*9)+8] + p[2] ] // vertex 3
);
}
return aReturnVertexArray;
};
obj_quad.prototype.getTextureCoords = function(){ return this.aTextureCoords; };
// SCENE GRAPH /////////////////////////////
// NODE oooo
function sceneNode(geometry){
this.iParentIndex = -1;
this.aChildrenIndices= [];
this.geometry = geometry;
}
sceneNode.prototype.setParent = function(index){
var oldParent = this.iParentIndex;
this.iParentIndex = index;
return oldParent;
};
sceneNode.prototype.hasChildren = function(){ return this.aChildrenIndices.length > 0; };
// GRAPH oooo
function sceneGraph(){
// vertex data
this.aVertexPositions = [];
this.aTextureCoords = [];
// graph
this.graph = [];
// create root node
this.rootIndex = this.addChild(new sceneNode(null), null);
}
sceneGraph.prototype.addChild = function(node, parentIndex)
{
var iNodeIndex = this.graph.push(node) - 1;
if(parentIndex != null){
node.setParent(parentIndex);
this.getNode(parentIndex).aChildrenIndices.push(iNodeIndex);
}else{}
return iNodeIndex;
};
sceneGraph.prototype.getRoot = function(){ return this.getNode(this.rootIndex); };
sceneGraph.prototype.getNode = function(index){ return this.graph[index]; };
sceneGraph.prototype.clearBuffers = function(){ this.aVertexPositions = []; this.aTextureCoords = []; };
sceneGraph.prototype.rebuildBuffers = function(){
this.clearBuffers();
this.rebuildBuffersAtNode( this.getRoot() );
};
sceneGraph.prototype.rebuildBuffersAtNode = function(node)
{
// PROCESS SELF
// concat geometry and texture coords to list
if(node.geometry != null){
// this.aVertexPositions = this.aVertexPositions.concat(node.geometry.getVertexPositions());
// this.aTextureCoords = this.aTextureCoords.concat(node.geometry.getTextureCoords());
var aPos = node.geometry.getVertexPositions();
var aPosL = aPos.length;
var aTex = node.geometry.getTextureCoords();
var aTexL = aTex.length;
for(var i = 0; i< aPosL; i++){
this.aVertexPositions.push(aPos[i]);
}
for(var i = 0; i< aTexL; i++){
this.aTextureCoords.push(aTex[i]);
}
}else{}
// PROCESS CHILDREN
// no children? do nothing, else rebuild foreach child
if(!node.hasChildren()){ return; }
else{
var children = node.aChildrenIndices;
for(var i = 0; i<children.length; i++){
this.rebuildBuffersAtNode(this.getNode( children[i] ));
}
}
};
</script>
</head>
<body onload=“webGLStart();”>
<canvas id="lesson09-canvas" style="border: none;" width="569" height="320"></canvas>
<div class="console"><span></span><input type="text" /></div>
</body>
</html>