Having looked at the different types of texture mapped and coloured materials with different lighting aspects, I want to discuss briefly a couple of more interactive and dynamic materials. These don’t necessarily add realism to a scene but can be very useful for 3D website development to provide a richer user experience. To do this we use the MovieMaterial and VideoMaterial. If you’re interested here is an equivalent tutorial for movie materials in Papervision3D.
Previous articles summary :
- First steps in Away3D : Part 1 - Getting started
Creation of a new Away3D project within eclipse or Flex Builder 3 and a simple example of a 3D scene illustrating some basic Away3D classes. - First steps in Away3D : Part 2 - Animation
Adding some basic animation to the scene by modifying 3D object parameters when entering a frame. - First steps in Away3D : Part 3 - Texture mapping
Create materials using bitmap data with the aim of having more detailed or realistic objects. - First steps in Away3D : Part 4 - Scene interaction
Listen to mouse events to interact with the scene and with individual rendered objects. - First steps in Away3D : Part 5 - Lighting and shading
A light source is added to the scene and the materials are changed to illustrate how Away3D renders objects with different shading characteristics. - First steps in Away3D : Part 6 - Normal mapping and environment mapping
Additional realism is added to the scene by applying normal maps (to give a higher level of shading characteristics) and environment maps (as an alternative to simple lighting models).
This article will be looking at two materials available in Away3D: the MovieMaterial for rendering Flash movies onto a surface and VideoMaterial to show Flash video streams on an object (using .flv files). As well as being able to show another Flash animation, mouse events are mapped to the MovieMaterial allowing it to be interactive, even in a 3D environment.
This article is taking advantage of the very latest types of materials available with Away3D: to be able to compile the examples and develop your own you may need to use the repository (SVN) version of Away3D. If you don’t have away3d.materials.VideoMaterial available in your Away3D source then this is the case. Here’s an article on downloading and installing Away3D from SNV if you need help.
To illustrate these different materials we’re going to create three simple planes rotated and translated to form three sides of a cube. Two of these will be rendered using MovieMaterials and the third with a Flash video stream using a VideoMaterial. One of the MovieMaterial planes will be interactive and I’ll show how a external Flash movie can be embedded in the compiled animation.
So let’s look at the code. For this example we will actually have three ActionScript classes: the main Away3D class and two additional classes to be used for the individual MovieMaterial cube faces. The latter two will be shown at the end of the article just for completeness.
package { import away3d.cameras.HoverCamera3D; import away3d.containers.ObjectContainer3D; import away3d.containers.Scene3D; import away3d.containers.View3D; import away3d.core.base.Object3D; import away3d.core.render.Renderer; import away3d.events.MouseEvent3D; import away3d.materials.MovieMaterial; import away3d.materials.VideoMaterial; import away3d.primitives.Plane; import caurina.transitions.Tweener; import flash.display.Sprite; import flash.display.StageAlign; import flash.display.StageScaleMode; import flash.events.Event; import flash.filters.BlurFilter; [SWF(backgroundColor="#222222")] public class Example007 extends Sprite { private var videoURL:String = "http://www.tartiflop.com/away3d/FirstSteps/AmIWrong.flv"; [Embed(source="/../assets/DrawTool.swf")] private var DrawToolEmbedded:Class; private var scene:Scene3D; private var camera:HoverCamera3D; private var view:View3D; private var planeGroup:ObjectContainer3D; private var doRotation:Boolean = true; public function Example007() { // set up the stage stage.align = StageAlign.TOP_LEFT; stage.scaleMode = StageScaleMode.NO_SCALE; // Add resize event listener stage.addEventListener(Event.RESIZE, onResize); // Initialise Away3D init3D(); // Create the 3D objects createScene(); // Initialise frame-enter loop this.addEventListener(Event.ENTER_FRAME, loop); } /** * Initialise all 3D components. */ private function init3D():void { // Create a new scene where all the 3D object will be rendered scene = new Scene3D(); // Create a new camera, passing some initialisation parameters camera = new HoverCamera3D({zoom:25, focus:30, distance:200}); camera.yfactor = 1; // Create a new view that encapsulates the scene and the camera view = new View3D({scene:scene, camera:camera}); // center the view to the middle of the stage view.x = stage.stageWidth / 2; view.y = stage.stageHeight / 2; // ensure that the z-order is calculated correctly view.renderer = Renderer.CORRECT_Z_ORDER; addChild(view); } /** * Create the objects of the scene */ private function createScene():void { // Video material using a flash streaming video URL var frontMaterial:VideoMaterial = new VideoMaterial({file:videoURL}); // Movie material from an embedded flash animation var topMaterial:MovieMaterial = new MovieMaterial(new DrawToolEmbedded(), {lockW:320, lockH:240, interactive:true, smooth:true, precision:5}); // Movie material from another class var leftMaterial:MovieMaterial = new MovieMaterial(new Pong(), {smooth:true, precision:5}); // Create three planes with different material, blurred by default // and position them them to create tree sides of a cube var topPlane:Plane = new Plane({material:topMaterial, width:100, height:100, segmentsW:2, segmentsH:2, ownCanvas:true}); topPlane.rotationY = -180; topPlane.y = 50; topPlane.filters.push(new BlurFilter(8, 8)); var leftPlane:Plane = new Plane({material:leftMaterial, width:100, height:100, segmentsW:2, segmentsH:2, ownCanvas:true}); leftPlane.rotationZ = -90; leftPlane.rotationY = -90; leftPlane.x = 50; leftPlane.filters.push(new BlurFilter(8, 8)); var frontPlane:Plane = new Plane({material:frontMaterial, width:100, height:100, segmentsW:2, segmentsH:2, ownCanvas:true}); frontPlane.rotationX = -90; frontPlane.rotationZ = 180; frontPlane.z = 50; frontPlane.filters.push(new BlurFilter(8, 8)); // Create an object container to group the sides of the cube planeGroup = new ObjectContainer3D(); scene.addChild(planeGroup); planeGroup.addChild(topPlane); planeGroup.addChild(leftPlane); planeGroup.addChild(frontPlane); // Add mouse listeners to each plane for mouse down, over and out events topPlane.addOnMouseDown(onMouseClickOnObject); leftPlane.addOnMouseDown(onMouseClickOnObject); frontPlane.addOnMouseDown(onMouseClickOnObject); topPlane.addOnMouseOver(onMouseOverObject); leftPlane.addOnMouseOver(onMouseOverObject); frontPlane.addOnMouseOver(onMouseOverObject); topPlane.addOnMouseOut(onMouseLeavesObject); leftPlane.addOnMouseOut(onMouseLeavesObject); frontPlane.addOnMouseOut(onMouseLeavesObject); } /** * Frame-enter event handler */ private function loop(event:Event):void { // update camera position updateCamera(); camera.hover(); // Render the 3D scene view.render(); } /** * Update the camera position from mouse positions */ private function updateCamera():void { if (doRotation) { camera.targetpanangle = (stage.stageWidth - stage.mouseX) / stage.stageWidth * 90; camera.targettiltangle = (stage.stageHeight - stage.mouseY) / stage.stageHeight * 70 } } /** * Event listener for mouse click on plane. Makes the camera look * directly at the plane and move closer to it. */ private function onMouseClickOnObject(event:MouseEvent3D):void { var object:Object3D = event.object; doRotation = false; // Calculate angles necessary for camera var theta:Number = Math.atan2(object.x, object.z); var len:Number = Math.sqrt(object.x*object.x + object.z*object.z); var phi:Number = Math.atan2(object.y, len); // rotate camera position camera.targetpanangle = theta * 180 / Math.PI; camera.targettiltangle = phi * 180 / Math.PI; // move camera towards plane Tweener.addTween(camera, {distance:150, time:0.5, transition:"easeOutSine"}); } /** * Event listener for mouse over plane. Removes the blur filter. */ private function onMouseOverObject(event:MouseEvent3D):void { var object:Object3D = event.object; object.filters = new Array(); } /** * Event listener for mouse out of plane. Adds blur filter and moves * camera away from plane if it isn't already. */ private function onMouseLeavesObject(event:MouseEvent3D):void { var object:Object3D = event.object; object.filters.push(new BlurFilter(8, 8)); Tweener.addTween(camera, {distance:200, time:0.5, transition:"easeOutSine"}); doRotation = true; } /** * Resize the scene when the stage resizes */ private function onResize(event:Event):void { view.x = stage.stageWidth / 2; view.y = stage.stageHeight / 2; } } }
This (along with the other ActionScript classes shown below) produces a cube that rotates as the mouse moves. One face shows a Flash video stream (Etienne de Crécy : Am I Wrong), another is an interactive drawable surface and the third a simple Pong simulation. Each face is blurred until the mouse enters it. Clicking on a face moves the camera directly above it and moves towards it. Moving the mouse outside of a face blurs the face again and the cube regains its original size. You can see the finished result by clicking on the image below.
As usual, the code for Example007 follows the same style as shown in the previous articles of this series. Lets look at what has changed.
The constructor and initialisation of Away3D elements is virtually identical to before so not worth looking at here. Lets move onto the scene creation (createScene())and see how we use these new materials. To start off with the materials are created.
// Video material using a flash streaming video URL var frontMaterial:VideoMaterial = new VideoMaterial({file:videoURL}); // Movie material from an embedded flash animation var topMaterial:MovieMaterial = new MovieMaterial(new DrawToolEmbedded(), {lockW:320, lockH:240, interactive:true, smooth:true, precision:5}); // Movie material from another class var leftMaterial:MovieMaterial = new MovieMaterial(new Pong(), {smooth:true, precision:5});
As you can see, the VideoMaterial is very easy to create - simply pass the URL of the Flash video stream in the initialisation parameters and its ready! Compared to Papervision3D this is much easier (in fact, the internet connection and creation of the video stream is encapsulated in the VideoMaterial class). If you want the video to loop you can set the loop parameter to true, also sent in the initialisation parameters array. If you want to pause/play the video then you can access the netStream object directly from the material to control the playback.
The MovieMaterial is similarly easy to create. The constructor takes a Sprite object which will be mapped to the surface. I show two cases here: one where the object is an instantiation of a class in the same project, another where we embed a previously compiled Flash movie. Other than the smooth and precision parameters as we discussed in the texture mapping tutorial, we can indicate that the material should be interactive by specifying the interactive parameter to be true. Similarly we can force a size of the Sprite by giving the lockW and lockH parameters being the width and height - note that these have no relation to the dimensions of the object to which the material is mapped.
These materials are used just like all the other materials presented in this series of articles: simply create an object and pass the material to it.
// Create three planes with different material, blurred by default // and position them them to create tree sides of a cube var topPlane:Plane = new Plane({material:topMaterial, width:100, height:100, segmentsW:2, segmentsH:2, ownCanvas:true}); topPlane.rotationY = -180; topPlane.y = 50; topPlane.filters.push(new BlurFilter(8, 8)); var leftPlane:Plane = new Plane({material:leftMaterial, width:100, height:100, segmentsW:2, segmentsH:2, ownCanvas:true}); leftPlane.rotationZ = -90; leftPlane.rotationY = -90; leftPlane.x = 50; leftPlane.filters.push(new BlurFilter(8, 8)); var frontPlane:Plane = new Plane({material:frontMaterial, width:100, height:100, segmentsW:2, segmentsH:2, ownCanvas:true}); frontPlane.rotationX = -90; frontPlane.rotationZ = 180; frontPlane.z = 50; frontPlane.filters.push(new BlurFilter(8, 8)); // Create an object container to group the sides of the cube planeGroup = new ObjectContainer3D(); scene.addChild(planeGroup); planeGroup.addChild(topPlane); planeGroup.addChild(leftPlane); planeGroup.addChild(frontPlane);
As you can see, three planes are created, as shown in previous articles, and a different material passed to each one. These planes are then rotated and translated to form three faces of a cube. I also added a BlurFilter just to show how adding effects to Away3D objects is very simple as well.
Moving onto the mouse event listeners, for this example I don’t have a stage mouse listener to rotate the scene, only listeners for the MouseEvent3D events. Here, for each face, I add a mouse down, mouse over and mouse up listener that are triggered only when the mouse interacts with a specific 3D object.
// Add mouse listeners to each plane for mouse down, over and out events topPlane.addOnMouseDown(onMouseClickOnObject); leftPlane.addOnMouseDown(onMouseClickOnObject); frontPlane.addOnMouseDown(onMouseClickOnObject); topPlane.addOnMouseOver(onMouseOverObject); leftPlane.addOnMouseOver(onMouseOverObject); frontPlane.addOnMouseOver(onMouseOverObject); topPlane.addOnMouseOut(onMouseLeavesObject); leftPlane.addOnMouseOut(onMouseLeavesObject); frontPlane.addOnMouseOut(onMouseLeavesObject);
Looking first at the mouse down listener, the objective is to make the camera look directly at a cube face.
/** * Event listener for mouse click on plane. Makes the camera look * directly at the plane and move closer to it. */ private function onMouseClickOnObject(event:MouseEvent3D):void { var object:Object3D = event.object; doRotation = false; // Calculate angles necessary for camera var theta:Number = Math.atan2(object.x, object.z); var len:Number = Math.sqrt(object.x*object.x + object.z*object.z); var phi:Number = Math.atan2(object.y, len); // rotate camera position camera.targetpanangle = theta * 180 / Math.PI; camera.targettiltangle = phi * 180 / Math.PI; // move camera towards plane Tweener.addTween(camera, {distance:150, time:0.5, transition:"easeOutSine"}); }
From the object location we can calculate a camera tilt and pan angle. Using the properties targetpanangle and targettiltangle the camera moves gently towards the desired position. To move the camera towards the object I’ve added a Tweener call (as we looked at in scene interaction). In this function we also turn off the automatic camera movement (the camera follows the mouse otherwise as shown below).
Moving on to the mouse over event listener, here we simply remove the BlurFilter making the face come into focus.
/** * Event listener for mouse over plane. Removes the blur filter. */ private function onMouseOverObject(event:MouseEvent3D):void { var object:Object3D = event.object; object.filters = new Array(); }
Finally for the listeners, the mouse out event listener adds the BlurFilter, ensures that the camera moves freely with the mouse movement again and executes another Tweener call to take the camera back to its original distance.
/** * Event listener for mouse out of plane. Adds blur filter and moves * camera away from plane if it isn't already. */ private function onMouseLeavesObject(event:MouseEvent3D):void { var object:Object3D = event.object; object.filters.push(new BlurFilter(8, 8)); Tweener.addTween(camera, {distance:200, time:0.5, transition:"easeOutSine"}); doRotation = true; }
That leaves us with just the event loop and the camera update function. You’ll notice that the update loop is very similar to before - there is no rotation applied to the scene objects this time but we do update the camera position.
To move the camera automatically, the relative mouse position on the screen is simply converted into pan and tilt angles and applied to the camera target angles as shown below.
/** * Update the camera position from mouse positions */ private function updateCamera():void { if (doRotation) { camera.targetpanangle = (stage.stageWidth - stage.mouseX) / stage.stageWidth * 90; camera.targettiltangle = (stage.stageHeight - stage.mouseY) / stage.stageHeight * 70 } }
Note that if the user has clicked on a cube face then the doRotation boolean is false allowing the user to interact more easily with the different movie or video stream material.
And that’s all there is to it! As you’ll see, mouse events are mapped effectively to the embedded Flash animations, even in 3D, and the streaming of video is very clean and very simple to implement. Hopefully this has provided a useful introduction to these types of materials, don’t hesitate to look into the code itself to understand them more. As always comments, questions and suggestions are very welcome too!
As promised, just for completeness, you’ll find the source for the drawing tool movie and the automated Pong game below - going into the detail of these is out of the scope of this article !
DrawTool.as :
package { import flash.display.Sprite; import flash.events.MouseEvent; import flash.text.TextField; import flash.text.TextFieldAutoSize; import flash.text.TextFormat; public class DrawTool extends Sprite { private var isDrawing:Boolean = false; private var sprite:Sprite; public function DrawTool() { // create a drawing surface sprite = new Sprite(); sprite.graphics.beginFill(0xEEEEEE); sprite.graphics.moveTo(0, 0); sprite.graphics.lineTo(320, 0); sprite.graphics.lineTo(320, 240); sprite.graphics.lineTo(0, 240); sprite.graphics.endFill(); addChild(sprite); // create text and format var textFormat:TextFormat = new TextFormat(); textFormat.size = 30; textFormat.font = "Arial"; var text:TextField = new TextField(); text.x = 50; text.y = 100; text.textColor = 0x222222; text.text = "click to draw!"; text.setTextFormat(textFormat); text.autoSize = TextFieldAutoSize.LEFT; text.selectable = false; addChild(text); // listen to mouse events this.addEventListener(MouseEvent.MOUSE_DOWN, onMouseDown); this.addEventListener(MouseEvent.MOUSE_UP, onMouseUp); this.addEventListener(MouseEvent.MOUSE_MOVE, onMouseMove); } /** * Event listener for mouse down event. Starts drawing circles. */ private function onMouseDown(event:MouseEvent):void { isDrawing = true; drawCircle(this.mouseX, this.mouseY); } /** * Event listener for mouse up event. Stops drawing circles. */ private function onMouseUp(event:MouseEvent):void { isDrawing = false; } /** * Event listener for mouse move event. Draws a circle. */ private function onMouseMove(event:MouseEvent):void { if (isDrawing) { drawCircle(this.mouseX, this.mouseY); } } /** * Function to draw a circle. */ private function drawCircle(x:int, y:int):void { sprite.graphics.beginFill(Math.random() * 0xFFFFFF, 0.5); sprite.graphics.drawCircle(x, y, 5); sprite.graphics.endFill(); } } }
Pong.as :
package { import flash.display.Sprite; import flash.events.Event; [SWF(backgroundColor="#000000")] /** * Simple computerised Pong copy. */ public class Pong extends Sprite { private static const COURT_WIDTH:Number = 320; private static const COURT_HEIGHT:Number = 240; private static const BALL_WIDTH:Number = 5; private static const BAT_WIDTH:Number = 5; private static const BAT_HEIGHT:Number = 30; private static const COLOUR:Number = 0xDDDDDD; private var player1:Sprite; private var player2:Sprite; private var ball:Sprite; private var ballSpeedX:Number; private var ballSpeedY:Number; private var activePlayer:int; private var playerIsMoving:Boolean = false; private var playerSpeed:Number; private var playerDestination:Number; public function Pong() { createCourt(); addPlayer1(); addPlayer2(); addBall(); this.addEventListener(Event.ENTER_FRAME, onFrameEnter); } /** * Creates the court with net */ private function createCourt():void { var background:Sprite = new Sprite(); background.graphics.beginFill(0x000000); background.graphics.moveTo(0, 0); background.graphics.lineTo(COURT_WIDTH, 0); background.graphics.lineTo(COURT_WIDTH, COURT_HEIGHT); background.graphics.lineTo(0, COURT_HEIGHT); background.graphics.endFill(); addChild(background); var net:Sprite = new Sprite(); var nPoints:Number = 32; var pointHeight:Number = (COURT_HEIGHT / nPoints); var drawHeight:Number = pointHeight * 0.6; var drawWidth:Number = drawHeight / 2; // Create dashed net for (var i:Number = 0; i < nPoints; i++) { var x:Number = COURT_WIDTH / 2 - drawWidth / 2; var y:Number = i*pointHeight; net.graphics.beginFill(COLOUR); net.graphics.moveTo(x, y); net.graphics.lineTo(x+drawWidth, y); net.graphics.lineTo(x+drawWidth, y+drawHeight); net.graphics.lineTo(x, y+drawHeight); net.graphics.endFill(); } addChild(net); } /** * Add left-hand player */ private function addPlayer1():void { player1 = new Sprite(); createBat(player1); player1.x = 20; player1.y = (COURT_HEIGHT / 2) - (BAT_HEIGHT / 2); addChild(player1); } /** * Add right-hand player */ private function addPlayer2():void { player2 = new Sprite(); createBat(player2); player2.x = COURT_WIDTH - 20 - BAT_WIDTH; player2.y = (COURT_HEIGHT / 2) - (BAT_HEIGHT / 2); addChild(player2); } /** * Create a bat */ private function createBat(player:Sprite):void { player.graphics.beginFill(COLOUR); player.graphics.moveTo(0, 0); player.graphics.lineTo(BAT_WIDTH, 0); player.graphics.lineTo(BAT_WIDTH, BAT_HEIGHT); player.graphics.lineTo(0, BAT_HEIGHT); player.graphics.endFill(); } /** * Add ball to scene and initialise speeds */ private function addBall():void { ball = new Sprite(); ball.graphics.beginFill(COLOUR); ball.graphics.moveTo(0, 0); ball.graphics.lineTo(BALL_WIDTH, 0); ball.graphics.lineTo(BALL_WIDTH, BALL_WIDTH); ball.graphics.lineTo(0, BALL_WIDTH); ball.graphics.endFill(); ball.x = 0; ball.y = 20; addChild(ball); ballSpeedX = 6; ballSpeedY = 5; activePlayer = 1; } /** * Called at every frame */ private function onFrameEnter(event:Event):void { // update ball position updateBall(); // update player position updateActivePlayer(); // detect hits hitTest(); } /** * Updates the ball position taking into account court dimensions */ private function updateBall():void { ball.x = ball.x + ballSpeedX; ball.y = ball.y + ballSpeedY; // Detect if ball escapes a player if (ball.x > COURT_WIDTH - BALL_WIDTH) { ball.x = 0; ball.y = COURT_HEIGHT / 2; ballSpeedY = Math.random() * 10; } else if (ball.x < 0) { ball.x = COURT_WIDTH - BALL_WIDTH; ball.y = COURT_HEIGHT / 2; ballSpeedY = -Math.random() * 10; } // Detect wall hits: invert ball y-direction and initiate player position calculation if (ball.y < 0) { ball.y = 0; ballSpeedY = -ballSpeedY; playerIsMoving = false; } else if (ball.y > COURT_HEIGHT - BALL_WIDTH) { ball.y = COURT_HEIGHT - BALL_WIDTH; ballSpeedY = -ballSpeedY; playerIsMoving = false; } } /** * Updates player position or calculates new position */ private function updateActivePlayer():void { var player:Sprite; var calculatedTime:Number; // calculate time for ball to reach player if (activePlayer == 0) { player = player1; calculatedTime = Math.abs(player.x + BAT_WIDTH - ball.x) / Math.abs(ballSpeedX); } else { player = player2; calculatedTime = Math.abs(player.x - (ball.x + BALL_WIDTH)) / Math.abs(ballSpeedX); } if (playerIsMoving) { // update player position player.y = player.y + playerSpeed; // ensure that player does not leave the court if (player.y > COURT_HEIGHT - BAT_HEIGHT) { player.y = COURT_HEIGHT - BAT_HEIGHT; playerIsMoving = false; } else if (player.y < 0) { player.y = 0; playerIsMoving = false; } // determine if player is more or less in position if (Math.abs(player.y - playerDestination) < BAT_HEIGHT / 4) { playerIsMoving = false; } } else { // calculate expected ball position var calculatedY:Number = ball.y + ballSpeedY * calculatedTime; var random:Boolean = false; if (calculatedY < 0 || calculatedY > COURT_HEIGHT) { calculatedY = Math.random() * COURT_HEIGHT; random = true; } // calculate desired player position with a random element playerDestination = calculatedY - (BAT_HEIGHT / 2) + (0.4 * (Math.random() - 0.5) * BAT_HEIGHT); if (playerDestination < 0) { playerDestination = 0; } else if (playerDestination > COURT_HEIGHT - BAT_HEIGHT) { playerDestination = COURT_HEIGHT - BAT_HEIGHT; } // Calculate a random speed for player if (player.y < playerDestination) { playerSpeed = 5 + Math.random() * 10; } else { playerSpeed = -5 + Math.random() * -10; } // increase speed if player can't work out ball position if (random) { playerSpeed = playerSpeed * 2; } // only move player if really necessary (+ stupidity estimate) if (Math.abs(playerDestination - player.y) > BAT_HEIGHT / 2) { playerIsMoving = true; } } } /** * Determine if ball hits a bat */ private function hitTest():void { var player:Sprite; var check:Boolean = false; // check to see if ball is at same x position as bat if (activePlayer == 0) { player = player1; if (ball.x > player1.x && ball.x < player1.x + BAT_WIDTH) { check = true; } } else { player = player2; if (ball.x + BALL_WIDTH > player2.x && ball.x + BALL_WIDTH < player2.x + BAT_WIDTH) { check = true; } } if (check) { // verify y position if ((ball.y + BALL_WIDTH <= player.y + BAT_HEIGHT) && (ball.y >= player.y)) { // hit, change player activePlayer = 1 - activePlayer; // reverse ball direction ballSpeedX = -ballSpeedX; // calculate new ball speed in y var batPosition:Number = (ball.y + (BALL_WIDTH / 2)) - (player.y + (BAT_HEIGHT / 2)); ballSpeedY = batPosition / BAT_HEIGHT * 10 * (1 + Math.random() * 0.1); } } } } }

