phetsims / bending-light

"Bending Light" is an educational simulation in HTML5, by PhET Interactive Simulations.
http://phet.colorado.edu/en/simulation/bending-light
GNU General Public License v3.0
8 stars 8 forks source link

White rays should be drawn with canvas to improve performance #161

Closed jonathanolson closed 8 years ago

jonathanolson commented 8 years ago

The performance for the Prisms screen for white light is abysmal, and I'd recommend modeling it after http://www.colorado.edu/physics/phet/dev/old-html5-tests-2011-2012/prism-break.html (which achieves much greater performance with many more rays).

It's possible to just draw rays directly with Canvas stroke() calls, using the blending mode:

context.globalCompositeOperation = "lighter";

so that when rays are drawn to the canvas, they only lighten the current pixels (exactly what is desired for this mode).

Assigning to @ariel-phet to decide who should work on this, and when.

samreid commented 8 years ago

It looks like that version is canvas and not webgl. Seems like a reasonable idea to try canvas first and see if performance is good enough. Here's a copy of the source code for safe keeping:


<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8"/>
    <meta name="viewport"
          content="width=device-width, height=device-height, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"/>
    <title>Prism Break</title>
    <link rel="stylesheet" type="text/css" href="reset.css"/>
    <script type="text/javascript" src="jquery-1.5.1.min.js"></script>
    <script type="text/javascript" src="common.js"></script>
    <script type="text/javascript" src="vec2.js"></script>
    <script type="text/javascript" src="ray2.js"></script>
    <script type="text/javascript" src="circle.js"></script>
    <script type="text/javascript" src="halfplane.js"></script>
    <script type="text/javascript" src="xyzdat.js"></script>
    <script type="text/javascript" src="materials.js"></script>
    <script type="text/javascript" src="reflection.js"></script>
    <script type="text/javascript" src="csg.js"></script>
    <script type="text/javascript">
        $( document ).ready( function() {
            var canvas = $( '#canvas' )[0];

            // stop text selection on the canvas
            canvas.onselectstart = function() {
                return false;
            };

            if ( canvas.getContext ) {
                var context = canvas.getContext( '2d' );

                var clearBackground = function() {
                    context.save();
                    context.globalCompositeOperation = "source-over";
                    context.fillStyle = "rgb(0,0,0)";
                    context.fillRect( 0, 0, canvas.width, canvas.height );
                    context.restore();
                };

                var draggedSurface = null;
                var dragging = false;

                var quality = 25;
                var QUALITIES = {
                    low: 50,
                    med: 25,
                    high: 5,
                    ridiculous: 2
                };

                var brightness = 255;

                var modelToView = function( point ) {
                    return point;
                };
                var viewToModel = function( point ) {
                    return point;
                };
                var modelToViewDelta = function( vec ) {
                    return vec;
                };
                var viewToModelDelta = function( vec ) {
                    return vec;
                };
                var modelToViewDistance = function( scalar ) {
                    return scalar;
                };
                var viewToModelDistance = function( scalar ) {
                    return scalar;
                };

                var lastMouseX = 0;
                var lastMouseY = 0;

                var outsideMaterial = light.AIR;

                var circle1 = new light.Circle( new light.Vec2( 100, 100 ), 50 );
                var circle2 = new light.Circle( new light.Vec2( 250, 100 ), 50 );
                var circle3 = new light.Circle( new light.Vec2( 400, 100 ), 50 );

                circle1.material = light.FUSED_SILICA;
                circle2.material = light.FUSED_SILICA;
                circle3.material = light.FUSED_SILICA;

                var surfaces = [
                    circle1,
                    circle2,
                    circle3
                ];

                var drawSurfaces = function() {
                    context.globalCompositeOperation = "source-over";
                    for ( var ob in surfaces ) {
                        var surface = surfaces[ob]; // temporary workaround for Intellij failure. change to for each
                        if ( surface instanceof light.Circle ) {
                            context.save();
                            if ( surface == draggedSurface ) {
                                context.strokeStyle = "rgb(255,255,255)";
                                context.lineWidth = 2;
                            }
                            else {
                                context.strokeStyle = "rgb(128,128,128)";
                            }
                            context.fillStyle = "rgb(24,24,24)";
                            context.beginPath();
                            var viewCenter = modelToView( surface.center );
                            var viewRadius = modelToViewDistance( surface.radius );
                            context.arc( viewCenter.x, viewCenter.y, viewRadius, 0, Math.PI * 2, true );
                            context.closePath();
                            context.stroke();
                            context.fill();
                            context.restore();
                        }
                    }
                };

                var shootRay = function( ray, color, wavelength, maxSteps ) {
                    var viewRayPos;
                    if ( color[0] + color[1] + color[2] < 0.0002 * quality ) {
                        // threshold very small ray contributions
                        return;
                    }
                    var hit = light.findClosestHit( surfaces, ray );
                    context.globalCompositeOperation = "lighter";
                    var red = Math.min( 255, Math.floor( brightness * Math.sqrt( color[0] ) * (quality / 25) ) );
                    var green = Math.min( 255, Math.floor( brightness * Math.sqrt( color[1] ) * (quality / 25) ) );
                    var blue = Math.min( 255, Math.floor( brightness * Math.sqrt( color[2] ) * (quality / 25) ) );
                    context.strokeStyle = "rgb(" + red + "," + green + "," + blue + ")";
                    if ( hit ) {
                        context.beginPath();
                        viewRayPos = modelToView( ray.pos );
                        var viewHitPoint = modelToView( hit.hitPoint );
                        context.moveTo( viewRayPos.x, viewRayPos.y );
                        context.lineTo( viewHitPoint.x, viewHitPoint.y );
                        context.closePath();
                        context.stroke();
                        if ( maxSteps > 1 ) {
                            var incident = ray.dir;
                            var normal = hit.normal;
                            var nOutside = outsideMaterial( wavelength );
                            var nInside = hit.surface.material( wavelength );
                            var na = hit.fromOutside ? nOutside : nInside;
                            var nb = hit.fromOutside ? nInside : nOutside;
                            var tir = light.isTotalInternalReflection( incident, normal, na, nb );
                            var transmittedDir = null;
                            if ( !tir ) {
                                transmittedDir = light.transmit( incident, normal, na, nb );
                            }
                            var reflectance = (tir ? {s:1,p:1} : light.fresnelDielectric( incident, normal, transmittedDir, na, nb ));
                            var simpleReflectance = (reflectance.s + reflectance.p) / 2; // TODO: improve for keeping track of polarization
                            var reflectedColor = [color[0] * simpleReflectance, color[1] * simpleReflectance, color[2] * simpleReflectance];
                            shootRay( new light.Ray2( hit.hitPoint, light.reflect( incident, normal ) ), reflectedColor, wavelength, maxSteps - 1 );
                            if ( !tir ) {
                                var simpleTransmittance = 1 - simpleReflectance;
                                var transmittedColor = [color[0] * simpleTransmittance, color[1] * simpleTransmittance, color[2] * simpleTransmittance];
                                shootRay( new light.Ray2( hit.hitPoint, transmittedDir ), transmittedColor, wavelength, maxSteps - 1 );
                            }
                        }
                    }
                    else {
                        var miss = ray.withDistance( 3000 );
                        context.beginPath();
                        viewRayPos = modelToView( ray.pos );
                        var viewHorizon = modelToView( miss );
                        context.moveTo( viewRayPos.x, viewRayPos.y );
                        context.lineTo( viewHorizon.x, viewHorizon.y );
                        context.closePath();
                        context.stroke();
                    }
                };

                var draw = function() {
                    //var ray1 = new light.Ray2( viewToModel( new light.Vec2( lastMouseX, lastMouseY ) ), new light.Vec2( -1, -1 ) );
                    var ray1 = new light.Ray2( viewToModel( new light.Vec2( canvas.width / 2, 0 ) ), new light.Vec2( 0, 1 ) );

                    clearBackground();

                    drawSurfaces();

                    for ( var wavelength = 400; wavelength <= 750; wavelength += quality ) {
                        shootRay( ray1, light.xyzdat[wavelength], wavelength, 10 );
                    }
                };

                var isOverSurface = function( viewPoint ) {
                    var modelPoint = viewToModel( viewPoint );
                    for ( var sidx in surfaces ) {
                        var surface = surfaces[sidx];
                        if ( surface.isPointInside( modelPoint ) ) {
                            return true;
                        }
                    }
                    return false;
                };

                var updateCursor = function( x, y ) {
                    if ( isOverSurface( new light.Vec2( x, y ) ) ) {
                        $( canvas ).css( "cursor", "pointer" );
                    }
                    else {
                        $( canvas ).css( "cursor", "auto" );
                    }
                };

                var resizer = function() {
                    canvas.width = $( window ).width();
                    canvas.height = $( window ).height();
                    draw();
                };
                $( window ).resize( resizer );

                var moveListener = function( x, y ) {
                    if ( dragging ) {
                        var delta = viewToModelDelta( new light.Vec2( x - lastMouseX, y - lastMouseY ) );
                        // TODO: figure out how to handle more than just a circle. probably use canvas transformations
                        if ( draggedSurface instanceof light.Circle ) {
                            draggedSurface.center = draggedSurface.center.add( delta );
                        }
                        draw();
                    }
                    updateCursor( x, y );
                    lastMouseX = x;
                    lastMouseY = y;
                };

                var downListener = function( x, y ) {
                    draggedSurface = null;
                    var modelMousePoint = viewToModel( new light.Vec2( x, y ) );
                    for ( var sidx in surfaces ) {
                        var surface = surfaces[sidx];
                        if ( surface.isPointInside( modelMousePoint ) ) {
                            draggedSurface = surface;
                        }
                    }
                    dragging = draggedSurface != null;
                    updateCursor( x, y );
                    draw();
                    lastMouseX = x;
                    lastMouseY = y;
                };

                var upListener = function( x, y ) {
                    dragging = false;
                    draggedSurface = null;
                    updateCursor( x, y );
                    draw();
                };

                var touchFromJQueryEvent = function( evt ) {
                    return evt.originalEvent.targetTouches[0];
                };

                $( canvas ).bind( "mousemove", function( evt ) {
                    evt.preventDefault();
                    moveListener( evt.pageX, evt.pageY );
                } );
                $( canvas ).bind( "mousedown", function( evt ) {
                    evt.preventDefault();
                    downListener( evt.pageX, evt.pageY );
                } );
                $( canvas ).bind( "mouseup", function( evt ) {
                    evt.preventDefault();
                    upListener( evt.pageX, evt.pageY );
                } );
                $( canvas ).bind( "touchmove", function( evt ) {
                    evt.preventDefault();
                    var touch = touchFromJQueryEvent( evt );
                    moveListener( touch.pageX, touch.pageY );
                } );
                $( canvas ).bind( "touchstart", function( evt ) {
                    evt.preventDefault();
                    var touch = touchFromJQueryEvent( evt );
                    downListener( touch.pageX, touch.pageY );
                } );
                $( canvas ).bind( "touchend", function( evt ) {
                    evt.preventDefault();
                    var touch = touchFromJQueryEvent( evt );
                    upListener( touch.pageX, touch.pageY );
                } );
                $( canvas ).bind( "touchcancel", function( evt ) {
                    evt.preventDefault();
                    var touch = touchFromJQueryEvent( evt );
                    upListener( touch.pageX, touch.pageY );
                } );
                resizer();

                $( '#quality' ).change( function() {
                    var selected = $( "#quality option:selected" );
                    quality = QUALITIES[selected.val()];
                    draw();
                } );

                $( '#brightness' ).change( function() {
                    brightness = $( "#brightness" ).val();
                    draw();
                } );

            }
        } );
    </script>
    <style type="text/css">
        html, body {
            width: 100%;
            height: 100%;
            margin: 0;
            padding: 0;
            background-color: #fff;
        }

        #canvas {
            position: absolute;
        }

        #topleftui {
            position: absolute;
            left: 0;
            top: 0;
            color: white;
            background-color: #222;
            padding: 0.25em;
            font-size: 12px;
        }

        #topleftui label:not(:first-child) {
            padding-left: 1em;
        }

    </style>
</head>

<body id="home">

<canvas id="canvas" width="1024" height="768">
    Fallback content
</canvas>

<div id="topleftui">
    <label for="quality">Quality</label>
    <select id="quality">
        <option value="low">Low</option>
        <option value="med" selected>Med</option>
        <option value="high">High</option>
        <option value="ridiculous">Ridiculous</option>
    </select>
    <label for="brightness">Brightness</label>
    <input id="brightness" type="range" min="32" max="2048" step="32" value="255"/>
</div>

</body>
</html>
samreid commented 8 years ago

The performance for the Prisms screen for white light is abysmal, and I'd recommend modeling it after http://www.colorado.edu/physics/phet/dev/old-html5-tests-2011-2012/prism-break.html (which achieves much greater performance with many more rays).

I am testing the link on my iPad3 running iOS8 and with the default setting of quality "medium", the performance is also abysmal. I'm estimating it at a little more than 1 frame/second when dragging a prism through light. This seems a little bit slower than the strategy used in bending light. @jonathanolson can you discuss this with me?

jonathanolson commented 8 years ago

I recall it being significantly faster on the iPad 3. I'll be charging and testing with mine today for a few things, I'll add this to the list.

That would suggest a WebGL view may be necessary for white light.

samreid commented 8 years ago

If I recall correctly, canvas performance dropped in iOS8.

jonathanolson commented 8 years ago

If it's iOS 8, then Canvas fallback with WebGL main may still be sufficient.

samreid commented 8 years ago

Any suggestions how to render the light rays with WebGL? I'm not sure where I'd start with this.

samreid commented 8 years ago

Use a strategy like canvas, and see if there is a similar blending mode, then just render each ray=2 triangles?

jonathanolson commented 8 years ago

Use a strategy like canvas, and see if there is a similar blending mode, then just render each ray=2 triangles?

Yup. Lots of blending modes available, it's very customizable.

jonathanolson commented 8 years ago

Also note that you could use lines instead of the two triangles (line strip for each ray that bounces), not sure if that would help.

jonathanolson commented 8 years ago

It is also not antialiased properly anymore on the iPad 3 (it used to be nicely antialiased and fast). Is it worth looking into details of what's going on?