Skip to content

Archive

Tag: O3D

On Friday, the Google O3D announced that they O3D has changed from a browser plug-in to an extension of WebGL.

In some ways, this is quite a good move, I think, but there is another side to the coin too.

The issue now, is that the O3D plug-in was able to run in just about any of the current crop of web browsers, now it will be a lot more restricted in available platform. WebGL itself is a work-in-progress, and is generally only available in the nightly builds of Firefox, Chrome and a few of the other major browsers, but I have yet to find an officially released version of a browser that supports WebGL.

What this means of course, is that it makes O3D less useful for writing web applications until these browsers get released with WebGL.

The other thing is that I am going to have to rewrite some of the O3D tutorials on this blog to take the WebGL change into account, so watch this space for updated tutorials coming soon…

Share

Now we are going to play around a bit with more than one light.

The way to do this is to define another set of lighting variables in the shader for the second light and then add in the values within the pixel shader for the second light.

The changes to the Javascript code are minimal. We now define 2 sets of global variables to define the lights location and colour.

var g_lightPosition1 = [10, 300, 7];
var g_lightColor1 = [0, 1, 1, 1];

var g_lightPosition2 = [-30, 40, 30];
var g_lightColor2 = [1, 0, 0, 1];

Then the only other function that needs to change is createPhoneMaterial. Here we now reference our updated shader, and then pass it the new parameters which we have set up. The emissive, ambient, diffuse, specular, shininess and colourMult values are common for the object, and are not related to the lights themselves so only have to be specified once.

function createPhongMaterial(baseColor) {
   // Load effect
  var effect = g_pack.createObject('Effect');
  var shaderString = 'shaders/phongtexmultilight.shader';
  o3djs.effect.loadEffect(effect, shaderString);

  // Create a new, empty Material object.
  var material = g_pack.createObject('Material');

  material.drawList = g_viewInfo.performanceDrawList;

  material.effect = effect;
  effect.createUniformParameters(material);

  // Assign parameters to the phong material.
  material.getParam('emissive').value = [0, 0, 0, 1];
  material.getParam('ambient').value = g_math.mulScalarVector(0.1, baseColor);
  material.getParam('diffuse').value = g_math.mulScalarVector(0.9, baseColor);
  material.getParam('specular').value = [.2, .2, .2, 1];
  material.getParam('shininess').value = 20;
  material.getParam('lightIntensity1').value = g_lightColor1;
  material.getParam('lightWorldPos1').value = g_lightPosition1;
  material.getParam('ambientIntensity').value = [0.2, 0.2, 0.2, 1];

  material.getParam('colorMult').value = [1, 1, 1, 1];

  material.getParam('lightIntensity2').value = g_lightColor2;
  material.getParam('lightWorldPos2').value = g_lightPosition2;

  var samplerParam = material.getParam('texSampler0');
  g_sampler = g_pack.createObject('Sampler');
  g_sampler.minFilter = g_o3d.Sampler.ANISOTROPIC;
  g_sampler.maxAnisotropy = 4;
  samplerParam.value = g_sampler;

  return material;
}

continue reading…

Share

Up to now, we have been able to use a texture shader, or the built-in phong shader. Now what we are going to do is combine the texture shader with a phong shader.

What this will do is enable us to have lighting effects combined with a texture for our terrain.

In the Javascript code, there is not much that has not been covered in previous tutorials, so I will basically just go over the code which has changed.

We only have one new variable, and that is to keep track of the light color – in this case a white light.

var g_lightColor = [1, 1, 1, 1];

The createPhongMaterial function has changed. Instead of using the standard shader, we now load the shader from a shader file, and then set up the parameters as usual. The sampler code will be familiar to you as the code we used in the tutorial on how to load a texture. The only unfamiliar value here is the colorMult parameter which can act as a colour filter on the end resulting colour. In this example we set it to white, so that it will have no effect.

function createPhongMaterial(baseColor) {
  var effect = g_pack.createObject('Effect');
  var shaderString = 'shaders/phongtex.shader';
  o3djs.effect.loadEffect(effect, shaderString);

  var material = g_pack.createObject('Material');
  material.drawList = g_viewInfo.performanceDrawList;
  material.effect = effect;
  effect.createUniformParameters(material);

  material.getParam('emissive').value = [0, 0, 0, 1];
  material.getParam('ambient').value = g_math.mulScalarVector(0.1, baseColor);
  material.getParam('diffuse').value = g_math.mulScalarVector(0.9, baseColor);
  material.getParam('specular').value = [.2, .2, .2, 1];
  material.getParam('shininess').value = 20;
  material.getParam('lightIntensity').value = g_lightColor;
  material.getParam('lightWorldPos').value = g_lightPosition;
  material.getParam('ambientIntensity').value = [0.2, 0.2, 0.2, 1];
  material.getParam('colorMult').value = [1, 1, 1, 1];

  var samplerParam = material.getParam('texSampler0');
  g_sampler = g_pack.createObject('Sampler');
  g_sampler.minFilter = g_o3d.Sampler.ANISOTROPIC;
  g_sampler.maxAnisotropy = 4;
  samplerParam.value = g_sampler;
	
  return material;
}

The loadLandscape function has changed so that now we load the texture we are going to use for the terrain, and set the sampler accordingly, after which we create the shape.

function loadLandscape()
{
  var landscapeMaterial = createPhongMaterial([0, 1, 0, 1]);
  
  o3djs.io.loadTexture(g_pack, 'tutorial19/image.png',
      function(texture, exception) {
        if (exception) {
          g_sampler.texture = null;
        } else {
          g_sampler.texture = texture;
	  var landscapeShape = createLandscape(landscapeMaterial);

	  g_landscapeTransform = g_pack.createObject('Transform');
	  g_landscapeTransform.addShape(landscapeShape);
	  g_landscapeTransform.parent = g_3dRoot;

	  landscapeShape.createDrawElements(g_pack, null);
	}
      });  
}

continue reading…

Share

Now that we can generate a terrain, we need to make it look a little bit better.

So, what we are going to do is to strip out the red shader we used in the last tutorial, and replace it with a phong material, created by the createPhongMaterial function we used previously to add some lighting to our primitives.

function createPhongMaterial(baseColor) {
  var material = g_pack.createObject('Material');

  o3djs.effect.attachStandardShader(
      g_pack, material, g_lightPosition, 'phong');

  material.drawList = g_viewInfo.performanceDrawList;

  material.getParam('emissive').value = [0, 0, 0, 1];
  material.getParam('ambient').value = g_math.mulScalarVector(0.1, baseColor);
  material.getParam('diffuse').value = g_math.mulScalarVector(0.9, baseColor);
  material.getParam('specular').value = [.2, .2, .2, 1];
  material.getParam('shininess').value = 20;

  return material;
}

In order to apply the phong shader to our terrain, we need to first calculate the normal for each triangle in the terrain, and that required a bit of reworking the createLandscape function to use the VertexInfo object to make creating the indices easier. We create a stream for the position of the vertex, the normal, and the coords of the texture used, followed by some mundane setup.

function createLandscape(material) {
  var vertexInfo = o3djs.primitives.createVertexInfo();
  var positionStream = vertexInfo.addStream(
      3, o3djs.base.o3d.Stream.POSITION);
  var normalStream = vertexInfo.addStream(
      3, o3djs.base.o3d.Stream.NORMAL);
  var texCoordStream = vertexInfo.addStream(
      2, o3djs.base.o3d.Stream.TEXCOORD, 0);
	  
  var color;
  var avgColor;
  
  var startX = -50;
  var startY = 0;
  var startZ = -50;
  
  var XDelta = 1;
  var ZDelta = 1;
  var YMax = 30;
  var YMin = -30;
  var YDiff = YMax - YMin;
  
  var curX = startX;
  var curZ = startZ;
  var positionArray = [];
  var curIndex = 0;
  
  var uvCoords = [
    [0, 0],
    [1, 0],
    [1, 1],
    [0, 1]
  ];
  

Next, we create the position array for the vertices. The only change here is that we now have a multidimensional array for positionArray instead of a single dimentional array.

for(var i = 0; i < g_heightMapCanvas.width; i++) {
    for (var j = 0; j < g_heightMapCanvas.height; j++) {
      color = getPixelColor(i, j, g_heightMapImageData);
      avgColor = ((color[0] + color[1] + color[2]) / 3) / 255;
	  positionArray[curIndex] = [];
      positionArray[curIndex][0] = curX;
      positionArray[curIndex][1] = YMin + (YDiff * avgColor);
      positionArray[curIndex][2] = curZ;
      curIndex++;
      curZ = curZ + ZDelta;
    }
    curX = curX + XDelta;
    curZ = startZ;
  }
  

continue reading...

Share

In this tutorial, I create a height map.

What this entails is loading an image into an image object to get the colour information at each pixel. This image in the example is a 100×100 pixel greyscale image, where the brighter the colour, the higher the elevation of the terrain will be.

First, some new variables

var g_heightMapCanvas;
var g_heightMapCanvasContext;
var g_heightMapImageData;
var g_landscapeLoaded = false;
var g_heightMapLoaded = false;
var g_initRun = false;

The createLandscape function is used to set up the vertex data for the landscape. We then create a set of vertices, where each vertex is represented by one pixel of the height map. Then we create the set of triangles that will make up the surface of the terrain. One thing you will notice is that the code will create a landscape with a solid colour with no lighting. In order to do lighting, we need to work out the normal, which I will handle in the next tutorial.

function createLandscape(material) {
  var landscape = g_pack.createObject('Shape');
  var landscapePrimitive = g_pack.createObject('Primitive');
  var streamBank = g_pack.createObject('StreamBank');
  var color;
  var avgColor;

  landscapePrimitive.material = material;
  landscapePrimitive.owner = landscape;
  landscapePrimitive.streamBank = streamBank;

  landscapePrimitive.primitiveType = g_o3d.Primitive.TRIANGLELIST;

  landscapePrimitive.numberPrimitives = ((g_heightMapCanvas.width - 1) * (g_heightMapCanvas.height - 1)) * 2;
  landscapePrimitive.numberVertices = g_heightMapCanvas.width * g_heightMapCanvas.height;    

  var startX = -50;
  var startY = 0;
  var startZ = -50;

  var XDelta = 1;
  var ZDelta = 1;
  var YMax = 30;
  var YMin = -30;
  var YDiff = YMax - YMin;

  var curX = startX;
  var curZ = startZ;
  var positionArray = [];
  var curIndex = 0;
  for(var i = 0; i < g_heightMapCanvas.width; i++) {
    for (var j = 0; j < g_heightMapCanvas.height; j++) {
      color = getPixelColor(i, j, g_heightMapImageData);
      avgColor = ((color[0] + color[1] + color[2]) / 3) / 255;
      positionArray[curIndex] = curX;
      positionArray[curIndex+1] = YMin + (YDiff * avgColor);
      positionArray[curIndex+2] = curZ;
      curIndex = curIndex + 3;
      curZ = curZ + ZDelta;
    }
    curX = curX + XDelta;
    curZ = startZ;
  }
  curIndex = 0;
  var indicesArray = [];
  var curPosIndex = 0;
  for(var i = 0; i < g_heightMapCanvas.width - 1; i++) {
    for (var j = 0; j < g_heightMapCanvas.height - 1; j++) {
      indicesArray[curIndex] = curPosIndex;
      indicesArray[curIndex+1] = curPosIndex + g_heightMapCanvas.height;
      indicesArray[curIndex+2] = curPosIndex + 1;
      curIndex = curIndex + 3;

      indicesArray[curIndex] = curPosIndex + 1;
      indicesArray[curIndex+1] = curPosIndex + g_heightMapCanvas.height;
      indicesArray[curIndex+2] = curPosIndex + g_heightMapCanvas.height + 1;
      curIndex = curIndex + 3;
      curPosIndex++;
      }
  }

The first thing we do here in this function is to calculate the vertices, where the values are in a grid along the x, z axis, with the y value being the elevation based on the value of the pixel at that location in the height map.

  var curX = startX;
  var curZ = startZ;
  var positionArray = [];
  var curIndex = 0;
  for(var i = 0; i < g_heightMapCanvas.width; i++) {
    for (var j = 0; j < g_heightMapCanvas.height; j++) {
      color = getPixelColor(i, j, g_heightMapImageData);
      avgColor = ((color[0] + color[1] + color[2]) / 3) / 255;
      positionArray[curIndex] = curX;
      positionArray[curIndex+1] = YMin + (YDiff * avgColor);
      positionArray[curIndex+2] = curZ;
      curIndex = curIndex + 3;
      curZ = curZ + ZDelta;
    }
    curX = curX + XDelta;
    curZ = startZ;
  }

Next we calculate the surface triangles.

  curIndex = 0;
  var indicesArray = [];
  var curPosIndex = 0;
  for(var i = 0; i < g_heightMapCanvas.width - 1; i++) {
    for (var j = 0; j < g_heightMapCanvas.height - 1; j++) {
      indicesArray[curIndex] = curPosIndex;
      indicesArray[curIndex+1] = curPosIndex + g_heightMapCanvas.height;
      indicesArray[curIndex+2] = curPosIndex + 1;
      curIndex = curIndex + 3;

      indicesArray[curIndex] = curPosIndex + 1;
      indicesArray[curIndex+1] = curPosIndex + g_heightMapCanvas.height;
      indicesArray[curIndex+2] = curPosIndex + g_heightMapCanvas.height + 1;
      curIndex = curIndex + 3;
      curPosIndex++;
      }
  }

One way to be able to see the landscape better, until we get the lighting going, is to invert alternate triangles. Since the direction the triangle is facing determines whether it is drawn, we can reverse the order of the vertices to make the triangles upside down. This can be done with these lines, by just switching the statements for [curIndex+1] and [curIndex+2]

      indicesArray[curIndex] = curPosIndex + 1;
      indicesArray[curIndex+1] = curPosIndex + g_heightMapCanvas.height;
      indicesArray[curIndex+2] = curPosIndex + g_heightMapCanvas.height + 1;

and making them

      indicesArray[curIndex] = curPosIndex + 1;
      indicesArray[curIndex+1] = curPosIndex + g_heightMapCanvas.height + 1;
      indicesArray[curIndex+2] = curPosIndex + g_heightMapCanvas.height;

Once the height map data is loaded we are going to call the loadLandscape function which sets up the landscape. We are using the solid red shader we used for the cube. There is not much difference here to how we created the cube in tutorial 2.

function loadLandscape()
{
  var landscapeEffect = g_pack.createObject('Effect');
  var shaderString = 'shaders/solidred.shader';
  o3djs.effect.loadEffect(landscapeEffect, shaderString);

  var landscapeMaterial = g_pack.createObject('Material');
  landscapeMaterial.drawList = g_viewInfo.performanceDrawList;
  landscapeMaterial.effect = landscapeEffect;

  var landscapeShape = createLandscape(landscapeMaterial);

  g_landscapeTransform = g_pack.createObject('Transform');
  g_landscapeTransform.addShape(landscapeShape);

  g_landscapeTransform.parent = g_3dRoot;

  landscapeShape.createDrawElements(g_pack, null);
}

The last change we have made is to create the client area using the ‘LargeGeometry’ flag. This is because a buffer can only normally hold 65535 vertices unless this flag is set. In our example here we have a 100×100 terrain, which works out to 10,000 vertices already, so if you are loading a large terrain, this flag needs to be set.

function init() {
  o3djs.util.makeClients(initStep2, 'LargeGeometry');
}

The HTML page now looks as follows. We load the heightmap image into an img tag

<html>
   <head>
      <meta http-equiv="content-type" content="text/html; charset=UTF-8">
      <title>Tutorial 17: Creating a landscape from a height map</title>
      <script type="text/javascript" src="o3djs/base.js"></script>
      <script type="text/javascript" src="tutorial17/tutorial17.js"></script>
   </head>
   <body>
      <h1>Tutorial 17: Creating a landscape from a height map</h1>
      
      <br/>
      <div id="o3d" style="width: 400px; height: 400px;"></div>
      <img src="tutorial17/heightmap.jpg" onload="loadHeightMap(this);" />
    </div>
  </body>
</html>

In the next tutorial, we will look at normals. These are required to do lighting, and is something the primitives in the previous tutorial did for you. Now we are going to have to do it for ourselves for our terrain.

continue reading…

Share

In the last tutorial, I promised to show to use heightmaps. While playing around with that, I realised that the camera set up to now is inadequate to navigate through the scene. This is not quite a trivial operation, so have decided to insert a tutorial on setting up the camera here.

The new controls for the camera, will rotate the world view as you move around the screen, much like a first person shooter. Using the WASD keys, you can go forwards and back, and left and right. this allows us to simulate “flying” around our scene.

First off we have added quite a bit of global variables

var g_lastMouseX = 0;
var g_lastMouseY = 0;
var g_mouseXDelta = 0;
var g_mouseYDelta = 0;
var g_rotationDelta = 0.002;
var g_translationDelta = 0.2;
var g_mouseLocked = false;
var g_lookingDir = [0, 0, 0];

First, on mouse click, we set a flag saying that we are moving our view. This is because we don’t want the screen looking in any direction unless we want it to. So to start moving the view, you just need to click once. So in the mousedown function we add.

   g_mouseLocked = !g_mouseLocked

Most of the action happens in the mouseMove function. We get the mouse coordinates, and then if the screen is locked into drag mode, we apply the transformation to move the screen. How this is done, is we need to work out our view direction, which is the location of the target minus the location of the eye.  We then apply the formula to rotate the vector an angle specified by g_rotationDelta multiplied by the distance moved since the last update. We then add back the location of the eye so that we can set the new target, and finally update the view with the new target location.

function mouseMove(e) {
  g_lastMouseX = g_mouseX;
  g_lastMouseY = g_mouseY;
  g_mouseX = e.x;
  g_mouseY = e.y;

  g_mouseXDelta = g_mouseX - g_lastMouseX;
  g_mouseYDelta = g_mouseY - g_lastMouseY;

  if (g_mouseLocked) {
    var viewDir = g_math.subVector(g_camera.target, g_camera.eye);

    var rotatedViewDir = [];
    rotatedViewDir[0] = (Math.cos(g_mouseXDelta * g_rotationDelta) * viewDir[0]) - (Math.sin(g_mouseXDelta * g_rotationDelta) * viewDir[2]);
    rotatedViewDir[1] = viewDir[1];
    rotatedViewDir[2] = (Math.cos(g_mouseXDelta * g_rotationDelta) * viewDir[2]) + (Math.sin(g_mouseXDelta * g_rotationDelta) * viewDir[0]);
    viewDir = rotatedViewDir;
    rotatedViewDir[0] = viewDir[0];
    rotatedViewDir[1] = (Math.cos(g_mouseYDelta * g_rotationDelta * -1) * viewDir[1]) - (Math.sin(g_mouseYDelta * g_rotationDelta * -1) * viewDir[2]);
    rotatedViewDir[2] = (Math.cos(g_mouseYDelta * g_rotationDelta * -1) * viewDir[2]) + (Math.sin(g_mouseYDelta * g_rotationDelta * -1) * viewDir[1]);
    g_lookingDir = rotatedViewDir;
    g_camera.target = g_math.addVector(rotatedViewDir, g_camera.eye);
    g_viewInfo.drawContext.view = g_math.matrix4.lookAt(g_camera.eye,
                                                     g_camera.target,
                                                     [0, 1, 0]);
  }
}

Next, the keyPressedAction function has been modified. Pressing ‘a’ or ‘d’ moves the camera left or right, and ‘w’ or ‘s’ moves the camera forwards and backwards. This takes into account the view direction, as we need to move in the correct manner, so we work out which way we are looking, apply the translation, and then add back the target, and finally setting the view. For the sideways translation, we need to update both the eye and target locations or else our view direction will drift.

function keyPressedAction(keyPressed, delta) {
  var actionTaken = false;
  switch(keyPressed) {
    case 'a':
		var eyeOriginal = g_camera.eye;
		var targetOriginal = g_camera.target;
		var viewEye = g_math.subVector(g_camera.eye, g_camera.target);
		var viewTarget = g_math.subVector(g_camera.target, g_camera.eye);
		viewEye = g_math.addVector([g_translationDelta * -1, 0, 0], viewEye);
		viewTarget = g_math.addVector([g_translationDelta * -1, 0, 0], viewTarget);
		g_camera.eye = g_math.addVector(viewEye, targetOriginal);
		g_camera.target = g_math.addVector(viewTarget, eyeOriginal);

		g_viewInfo.drawContext.view = g_math.matrix4.lookAt(g_camera.eye,
                                                       g_camera.target,
                                                       [0, 1, 0]);
      actionTaken = true;
      break;
    case 'd':
		var eyeOriginal = g_camera.eye;
		var targetOriginal = g_camera.target;
		var viewEye = g_math.subVector(g_camera.eye, g_camera.target);
		var viewTarget = g_math.subVector(g_camera.target, g_camera.eye);
		viewEye = g_math.addVector([g_translationDelta, 0, 0], viewEye);
		viewTarget = g_math.addVector([g_translationDelta, 0, 0], viewTarget);
		g_camera.eye = g_math.addVector(viewEye, targetOriginal);
		g_camera.target = g_math.addVector(viewTarget, eyeOriginal);

		g_viewInfo.drawContext.view = g_math.matrix4.lookAt(g_camera.eye,
                                                       g_camera.target,
                                                       [0, 1, 0]);
      actionTaken = true;
      break;
    case 'w':
		var view = g_math.subVector(g_camera.eye, g_camera.target);
		view = g_math.mulScalarVector( 11 / 12, view);
		g_camera.eye = g_math.addVector(view, g_camera.target);
		g_viewInfo.drawContext.view = g_math.matrix4.lookAt(g_camera.eye,
                                                       g_camera.target,
                                                       [0, 1, 0]);
      actionTaken = true;
      break;
    case 's':
		var view = g_math.subVector(g_camera.eye, g_camera.target);
		view = g_math.mulScalarVector( 13 / 12, view);
		g_camera.eye = g_math.addVector(view, g_camera.target);
		g_viewInfo.drawContext.view = g_math.matrix4.lookAt(g_camera.eye,
                                                       g_camera.target,
                                                       [0, 1, 0]);
      actionTaken = true;
      break;
  }
  return actionTaken;
}

I have also modified the output panel to display the location of the eye, and the target, so we can see what is happening behind the scenes.
continue reading…

Share

O3D has a peculiarity. For a graphics engine, it makes it rather tricky to handle images as simply images. This tutorial is a stepping stone for the next tutorial which I will put up which will be on how to create a landscape using a height map.

Before we get there, though, we need to first be able to determine the color at a particular spot on an image, which O3D does not directly allow, so you cannot just load the image into a texture, and try and get the color at a pixel location.

There is a solution though. Instead of using O3D to get the color information, we are going to load an image into an HTML canvas object, which DOES allow you to get at the color information we are wanting.

The sample application is a big departure from previous tutorials, and I have stripped most of it away. In the application, I am loading a texture onto a 2D canvas. The image represents a maze of sorts, and have created a mousemove event that updates the text onscreen depending on the color which the mouse is over. So, if the mouse is over the black part of the canvas, the text shows “border”. If you are on the white portion (the allowed area of the maze), it shows “good”, and if you are on the blue portion (which means you are in the middle of a wall), it shows “crashed”.

So looking at the code, we declare a few new globals.

var g_mazeImage;
var g_templateCanvas;
var g_templateCanvasContext;
var g_templateImageData;
var g_message = '';

The function to load the image data creates a canvas object, and a context linked to that canvas. Then the image passed to the function is inserted into the canvas, and finally an object containing the image data is created. It is this image data object that contains the color information we will use later.

function loadImageData(image)
{
  g_templateCanvas=document.createElement("canvas");
  g_templateCanvasContext=g_templateCanvas.getContext("2d");

  g_templateCanvas.width= image.width;
  g_templateCanvas.height=image.height;
  g_templateCanvasContext.drawImage(image,0,0);

  g_templateImageData = g_templateCanvasContext.getImageData(0,0, image.width, image.height);

}

The getPixelColor() function returns an array containing the red, green, blue and alpha components of the color at the specified location in the image. The color components have a range of 0 – 255.

function getPixelColor(x, y, imageData) {
  var index=(x * 4) * imageData.width + (y * 4);
  var colArray = [];

  colArray[0] = imageData.data[index];
  colArray[1] = imageData.data[index+1];
  colArray[2] = imageData.data[index+2];
  colArray[3] = imageData.data[index+3];
  return colArray;
}

Now in the mouseMove() event handler, we get the pixel color at our current location, and depending on what color we are over, sets the message to display.

function mouseMove(e) {
  g_mouseX = e.x;
  g_mouseY = e.y;

  var color = getPixelColor(g_mouseX, g_mouseY, g_templateImageData);

  if ((color[0] == 0) && (color[1] == 0) && (color[2] == 0)){
    g_message = "border";
  }else if ((color[0] == 255) && (color[1] == 255) && (color[2] == 255)){
    g_message = "good";
  } else {
    g_message = "crashed";
  }
}

continue reading…

Share