// // $Id$ // // Nenya library - tools for developing networked games // Copyright (C) 2002-2007 Three Rings Design, Inc., All Rights Reserved // http://www.threerings.net/code/nenya/ // // This library is free software; you can redistribute it and/or modify it // under the terms of the GNU Lesser General Public License as published // by the Free Software Foundation; either version 2.1 of the License, or // (at your option) any later version. // // This library is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU // Lesser General Public License for more details. // // You should have received a copy of the GNU Lesser General Public // License along with this library; if not, write to the Free Software // Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA package com.threerings.media; import java.applet.Applet; import java.awt.Component; import java.awt.Graphics2D; import java.awt.GraphicsDevice; import java.awt.KeyEventDispatcher; import java.awt.KeyboardFocusManager; import java.awt.Rectangle; import java.awt.Window; import java.awt.event.ComponentEvent; import java.awt.event.ComponentListener; import java.awt.event.KeyEvent; import java.awt.event.WindowEvent; import java.awt.event.WindowListener; import java.awt.EventQueue; import javax.swing.JLayeredPane; import javax.swing.RepaintManager; import javax.swing.JRootPane; import javax.swing.event.AncestorEvent; import javax.swing.event.AncestorListener; import com.samskivert.swing.RuntimeAdjust; import com.samskivert.util.ListUtil; import com.samskivert.util.RunAnywhere; import com.samskivert.util.StringUtil; import com.threerings.media.timer.MediaTimer; import com.threerings.media.timer.MillisTimer; import com.threerings.media.util.TrailingAverage; import com.threerings.util.unsafe.Unsafe; /** * Provides a central point from which the computation for each "frame" or tick can be dispatched. * This assumed that the application structures its activity around the rendering of each frame, * which is a common architecture for games. The animation and sprite support provided by other * classes in this package are structured for use in an application that uses a frame manager to * tick everything once per frame. * *

The frame manager goes through a simple two part procedure every frame: * *

* *

The ticking and rendering takes place on the AWT thread so as to avoid the need for * complicated coordination between AWT event handler code and frame code. However, this means that * all AWT (and Swing) event handlers must not perform any complicated processing. After * each frame, control of the AWT thread is given back to the AWT which processes all pending AWT * events before giving the frame manager an opportunity to process the next frame. Thus the * convenience of everything running on the AWT thread comes with the price of requiring that AWT * event handlers not block or perform any intensive processing. In general, this is a sensible * structure for an application anyhow, so this organization tends to be preferable to an * organization where the AWT and frame threads are separate and must tread lightly so as not to * collide. * *

Note: the way that JScrollPane goes about improving performance when scrolling * complicated contents cannot work with active rendering. If you use a JScrollPane in * an application that uses the frame manager, you should either use the provided {@link * SafeScrollPane} or set your scroll panes' viewports to SIMPLE_SCROLL_MODE. */ public abstract class FrameManager { /** * Normally, the frame manager will repaint any component in a {@link JLayeredPane} layer * (popups, overlays, etc.) that overlaps a frame participant on every tick because the frame * participant could have changed underneath the overlay which would require that the * overlay be repainted. If the application knows that the frame participant beneath the * overlay will never change, it can have its overlay implement this interface and avoid the * expense of forcibly fully repainting the overlay on every frame. */ public static interface SafeLayerComponent { } /** * Provides a bridge between either {@link ManagedJFrame} or {@link ManagedJApplet} and the * frame manager. */ public static interface ManagedRoot { /** Configures the root with a reference to its frame manager. */ public void init (FrameManager fmgr); /** Returns the window at the root of the UI hierarchy. */ public Window getWindow (); /** Returns the top-level Swing pane. */ public JRootPane getRootPane(); } /** * Creates a frame manager that will try to use a high resolution timer for timing but will * fall back to {@link MillisTimer}, which is available on every platform, but returns * inaccurate time stamps on many platforms. * * @see #newInstance(ManagedRoot, MediaTimer) */ public static FrameManager newInstance (ManagedRoot root) { MediaTimer timer = null; for (String timerClass : PERF_TIMERS) { try { timer = (MediaTimer)Class.forName(timerClass).newInstance(); break; } catch (Throwable t) { t.printStackTrace(System.err); // try the next one } } if (timer == null) { Log.info("Can't use high performance timer, reverting to " + "System.currentTimeMillis() based timer."); timer = new MillisTimer(); } return newInstance(root, timer); } /** * Constructs a frame manager that will do its rendering to the supplied root and use the * supplied media timer for timing information. */ public static FrameManager newInstance (ManagedRoot root, MediaTimer timer) { FrameManager fmgr = (root instanceof ManagedJFrame && _useFlip.getValue()) ? new FlipFrameManager() : new BackFrameManager(); fmgr.init(root, timer); return fmgr; } /** * Instructs the frame manager to target the specified number of frames per second. If the * computation and rendering for a frame are completed with time to spare, the frame manager * will wait until the proper time to begin processing for the next frame. If a frame takes * longer than its alotted time, the frame manager will immediately begin processing on the * next frame. */ public void setTargetFrameRate (int fps) { // compute the number of milliseconds per frame _millisPerFrame = 1000/fps; } /** * Registers a frame participant. The participant will be given the opportunity to do * processing and rendering on each frame. */ public void registerFrameParticipant (FrameParticipant participant) { Object[] nparts = ListUtil.testAndAddRef(_participants, participant); if (nparts == null) { Log.warning("Refusing to add duplicate frame participant! " + participant); } else { _participants = nparts; } } /** * Returns true if the specified participant is registered. */ public boolean isRegisteredFrameParticipant (FrameParticipant participant) { return ListUtil.containsRef(_participants, participant); } /** * Removes a frame participant. */ public void removeFrameParticipant (FrameParticipant participant) { ListUtil.clearRef(_participants, participant); } /** * Returns a millisecond granularity time stamp using the {@link MediaTimer} with which this * frame manager was configured. Note: this should only be called from the AWT * thread. */ public long getTimeStamp () { return _timer.getElapsedMillis(); } /** * Returns an overlay that can be used to render sprites and animations on top of the entire * frame. This is lazily created the first time this method is called and the overlay will * remain a frame participant until {@link #clearMediaOverlay} is called. Be sure to coordinate * access to the overlay in your application as there is only one overlay in existence at any * time, and attempts to use an overlay after it has been cleared will fail. */ public MediaOverlay getMediaOverlay () { if (_overlay == null) { _overlay = new MediaOverlay(this); } return _overlay; } /** * Clears out any media overlay that is in use. */ public void clearMediaOverlay () { if (_overlay != null) { _overlay = null; } } /** * Starts up the per-frame tick */ public void start () { if (_ticker == null) { _ticker = new Ticker(); _ticker.start(); _lastTickStamp = 0; } } /** * Stops the per-frame tick. */ public synchronized void stop () { if (_ticker != null) { _ticker.cancel(); _ticker = null; } } /** * Returns true if the tick interval is be running (not necessarily at that instant, but in * general). */ public synchronized boolean isRunning () { return (_ticker != null); } /** * Returns the number of ticks executed in the last second. */ public int getPerfTicks () { return Math.round(_fps[1]); } /** * Returns the number of ticks requested in the last second. */ public int getPerfTries () { return Math.round(_fps[0]); } /** * Returns debug performance metrics. */ public TrailingAverage[] getPerfMetrics () { if (_metrics == null) { _metrics = new TrailingAverage[] { new TrailingAverage(150), new TrailingAverage(150), new TrailingAverage(150) }; } return _metrics; } /** * Returns the root component for the supplied component or null if it is not part of a rooted * hierarchy or if any parent along the way is found to be hidden or without a peer. Along the * way, it adjusts the supplied component-relative rectangle to be relative to the returned * root component. */ public static Component getRoot (Component comp, Rectangle rect) { for (Component c = comp; c != null; c = c.getParent()) { if (!c.isVisible() || !c.isDisplayable()) { return null; } if (c instanceof Window || c instanceof Applet) { return c; } rect.x += c.getX(); rect.y += c.getY(); } return null; } /** * Initializes this frame manager and prepares it for operation. */ protected void init (ManagedRoot root, MediaTimer timer) { _window = root.getWindow(); _root = root; _root.init(this); _timer = timer; // set up our custom repaint manager _repainter = new ActiveRepaintManager( (_root instanceof Component) ? (Component)_root : _window); RepaintManager.setCurrentManager(_repainter); // turn off double buffering for the whole business because we handle repaints _repainter.setDoubleBufferingEnabled(false); } /** * Called to perform the frame processing and rendering. */ protected void tick (long tickStamp) { long start = 0L, paint = 0L; if (_perfDebug.getValue()) { start = paint = _timer.getElapsedMicros(); } // if our frame is not showing (or is impossibly sized), don't try rendering anything if (_window.isShowing() && _window.getWidth() > 0 && _window.getHeight() > 0) { // tick our participants tickParticipants(tickStamp); paint = _timer.getElapsedMicros(); // repaint our participants and components paint(tickStamp); } if (_perfDebug.getValue()) { long end = _timer.getElapsedMicros(); getPerfMetrics()[1].record((int)(paint-start)/100); getPerfMetrics()[2].record((int)(end-paint)/100); } } /** * Called once per frame to invoke {@link FrameParticipant#tick} on all of our frame * participants. */ protected void tickParticipants (long tickStamp) { long gap = tickStamp - _lastTickStamp; if (_lastTickStamp != 0 && gap > (HANG_DEBUG ? HANG_GAP : BIG_GAP)) { Log.debug("Long tick delay [delay=" + gap + "ms]."); } _lastTickStamp = tickStamp; // validate any invalid components try { _repainter.validateComponents(); } catch (Throwable t) { Log.warning("Failure validating components."); Log.logStackTrace(t); } // tick all of our frame participants for (int ii = 0; ii < _participants.length; ii++) { FrameParticipant part = (FrameParticipant)_participants[ii]; if (part == null) { continue; } try { long start = 0L; if (HANG_DEBUG) { start = System.currentTimeMillis(); } part.tick(tickStamp); if (HANG_DEBUG) { long delay = (System.currentTimeMillis() - start); if (delay > HANG_GAP) { Log.info("Whoa nelly! Ticker took a long time " + "[part=" + part + ", time=" + delay + "ms]."); } } } catch (Throwable t) { Log.warning("Frame participant choked during tick " + "[part=" + StringUtil.safeToString(part) + "]."); Log.logStackTrace(t); } } // if we have an overlay, tick that too if (_overlay != null) { _overlay.tick(tickStamp); } } /** * Called once per frame to invoke {@link Component#paint} on all of our frame participants' * components and all dirty components managed by our {@link ActiveRepaintManager}. */ protected abstract void paint (long tickStamp); /** * Returns a graphics context with which to layout its media objects. The returned context must * be disposed when layout is complete and must not be retained across frame ticks. Used by the * {@link MediaOverlay}. */ protected abstract Graphics2D createGraphics (); /** * Paints our frame participants and any dirty components via the repaint manager. * * @return true if anything was painted, false if not. */ protected boolean paint (Graphics2D gfx) { // paint our frame participants (which want to be handled specially) int painted = 0; for (int ii = 0; ii < _participants.length; ii++) { FrameParticipant part = (FrameParticipant)_participants[ii]; if (part == null) { continue; } Component pcomp = part.getComponent(); if (pcomp == null || !part.needsPaint()) { continue; } long start = 0L; if (HANG_DEBUG) { start = System.currentTimeMillis(); } // get the bounds of this component pcomp.getBounds(_tbounds); // the bounds adjustment we're about to call will add in the components initial bounds // offsets, so we remove them here _tbounds.setLocation(0, 0); // convert them into top-level coordinates; also note that if this component does not // have a valid or visible root, we don't want to paint it either if (getRoot(pcomp, _tbounds) == null) { continue; } try { // render this participant; we don't set the clip because frame participants are // expected to handle clipping themselves; otherwise we might pointlessly set the // clip here, creating a few Rectangle objects in the process, only to have the // frame participant immediately set the clip to something more sensible gfx.translate(_tbounds.x, _tbounds.y); pcomp.paint(gfx); gfx.translate(-_tbounds.x, -_tbounds.y); painted++; } catch (Throwable t) { String ptos = StringUtil.safeToString(part); Log.warning("Frame participant choked during paint [part=" + ptos + "]."); Log.logStackTrace(t); } // render any components in our layered pane that are not in the default layer _clipped[0] = false; renderLayers(gfx, pcomp, _tbounds, _clipped); if (HANG_DEBUG) { long delay = (System.currentTimeMillis() - start); if (delay > HANG_GAP) { Log.warning("Whoa nelly! Painter took a long time " + "[part=" + part + ", time=" + delay + "ms]."); } } } // if we have a media overlay, give that a chance to propagate dirty regions to the active // repaint manager for areas it dirtied during this tick if (_overlay != null) { _overlay.propagateDirtyRegions(_repainter, _root.getRootPane()); } // repaint any widgets that have declared they need to be repainted since the last tick boolean pcomp = _repainter.paintComponents(gfx, this); // if we have a media overlay, give it a chance to paint on top of everything if (_overlay != null) { pcomp |= _overlay.paint(gfx); } // let the caller know if anybody painted anything return ((painted > 0) || pcomp); } /** * Called by the {@link ManagedJFrame} when our window was hidden and reexposed. */ protected abstract void restoreFromBack (Rectangle dirty); /** * Renders all components in all {@link JLayeredPane} layers that intersect the supplied * bounds. */ protected void renderLayers (Graphics2D g, Component pcomp, Rectangle bounds, boolean[] clipped) { JLayeredPane lpane = JLayeredPane.getLayeredPaneAbove(pcomp); if (lpane != null) { renderLayer(g, bounds, lpane, clipped, JLayeredPane.PALETTE_LAYER); renderLayer(g, bounds, lpane, clipped, JLayeredPane.MODAL_LAYER); renderLayer(g, bounds, lpane, clipped, JLayeredPane.POPUP_LAYER); renderLayer(g, bounds, lpane, clipped, JLayeredPane.DRAG_LAYER); } } /** * Renders all components in the specified layer of the supplied layered pane that intersect * the supplied bounds. */ protected void renderLayer (Graphics2D g, Rectangle bounds, JLayeredPane pane, boolean[] clipped, Integer layer) { // stop now if there are no components in that layer int ccount = pane.getComponentCountInLayer(layer.intValue()); if (ccount == 0) { return; } // render them up Component[] comps = pane.getComponentsInLayer(layer.intValue()); for (int ii = 0; ii < ccount; ii++) { Component comp = comps[ii]; if (!comp.isVisible() || comp instanceof SafeLayerComponent) { continue; } // if this overlay does not intersect the component we just rendered, we don't need to // repaint it _tbounds.setBounds(0, 0, comp.getWidth(), comp.getHeight()); getRoot(comp, _tbounds); if (!_tbounds.intersects(bounds)) { continue; } // if the clipping region has not yet been set during this render pass, the time has // come to do so if (!clipped[0]) { g.setClip(bounds); clipped[0] = true; } // translate into the components coordinate system and render g.translate(_tbounds.x, _tbounds.y); try { comp.paint(g); } catch (Exception e) { Log.warning("Component choked while rendering."); Log.logStackTrace(e); } g.translate(-_tbounds.x, -_tbounds.y); } } /** Used to effect periodic calls to {@link #tick}. */ protected class Ticker extends Thread { public void run () { Log.info("Frame manager ticker running " + "[sleepGran=" + _sleepGranularity.getValue() + "]."); while (_running) { long start = 0L; if (_perfDebug.getValue()) { start = _timer.getElapsedMicros(); } Unsafe.sleep(_sleepGranularity.getValue()); long woke = _timer.getElapsedMicros(); if (start > 0L) { getPerfMetrics()[0].record((int)(woke-start)/100); int elapsed = (int)(woke-start); if (elapsed > _sleepGranularity.getValue()*1500) { Log.warning("Long tick [elapsed=" + elapsed + "us]."); } } // work around sketchy bug on WinXP that causes the clock to leap into the past // from time to time if (woke < _lastAttempt) { Log.warning("Zoiks! We've leapt into the past, coping as best we can " + "[dt=" + (woke - _lastAttempt) + "]."); _lastAttempt = woke; } if (woke - _lastAttempt >= _millisPerFrame * 1000) { _lastAttempt = woke; if (testAndSet()) { EventQueue.invokeLater(_awtTicker); } // else: drop the frame } } } public void cancel () { _running = false; } protected final synchronized boolean testAndSet () { _tries++; if (!_ticking) { _ticking = true; return true; } return false; } protected final synchronized void clearTicking (long elapsed) { if (++_ticks == 100) { long time = (elapsed - _lastTick); _fps[0] = _tries * 1000f / time; _fps[1] = _ticks * 1000f / time; _lastTick = elapsed; _ticks = _tries = 0; } _ticking = false; } /** Used to invoke the call to {@link #tick} on the AWT event queue thread. */ protected Runnable _awtTicker = new Runnable () { public void run () { long elapsed = _timer.getElapsedMillis(); try { tick(elapsed); } finally { clearTicking(elapsed); } } }; /** Used to stick a fork in our ticker when desired. */ protected transient boolean _running = true; /** Used to detect when we need to drop frames. */ protected boolean _ticking; /** The time at which we last attempted to tick. */ protected long _lastAttempt; /** Used to compute metrics. */ protected int _tries, _ticks, _time; /** Used to compute metrics. */ protected long _lastTick; }; /** The window into which we do our rendering. */ protected Window _window; /** Provides access to our Swing bits. */ protected ManagedRoot _root; /** Used to obtain timing measurements. */ protected MediaTimer _timer; /** Our custom repaint manager. */ protected ActiveRepaintManager _repainter; /** If active, an overlay that will be rendering sprites and animations on top of the frame. */ protected MediaOverlay _overlay; /** The number of milliseconds per frame (14 by default, which gives an fps of ~71). */ protected long _millisPerFrame = 14; /** Used to track big delays in calls to our tick method. */ protected long _lastTickStamp; /** The thread that dispatches our frame ticks. */ protected Ticker _ticker; /** Used to track and report frames per second. */ protected float[] _fps = new float[2]; /** Used to track performance metrics. */ protected TrailingAverage[] _metrics; /** A temporary bounds rectangle used to avoid lots of object creation. */ protected Rectangle _tbounds = new Rectangle(); /** Used to lazily set the clip when painting popups and other "layered" components. */ protected boolean[] _clipped = new boolean[1]; /** The entites that are ticked each frame. */ protected Object[] _participants = new Object[4]; /** If we don't get ticked for 500ms, that's worth complaining about. */ protected static final long BIG_GAP = 500L; /** If we don't get ticked for 100ms and we're hang debugging, complain. */ protected static final long HANG_GAP = 100L; /** Enable this to log warnings when ticking or painting takes too long. */ protected static final boolean HANG_DEBUG = false; /** A debug hook that toggles debug rendering of sprite paths. */ protected static RuntimeAdjust.BooleanAdjust _useFlip = new RuntimeAdjust.BooleanAdjust( "When active a flip-buffer will be used to manage our rendering, otherwise a " + "volatile back buffer is used [requires restart]", "narya.media.frame", // back buffer rendering doesn't work on the Mac, so we default to flip buffer on that // platform; we still allow it to be toggled so that we can easily test things when // they release new JVMs MediaPrefs.config, RunAnywhere.isMacOS()); /** Allows us to tweak the sleep granularity. */ protected static RuntimeAdjust.IntAdjust _sleepGranularity = new RuntimeAdjust.IntAdjust( "The number of milliseconds slept before checking to see if it's time to queue up a " + "new frame tick.", "narya.media.sleep_gran", MediaPrefs.config, RunAnywhere.isWindows() ? 10 : 7); /** A debug hook that toggles FPS rendering. */ protected static RuntimeAdjust.BooleanAdjust _perfDebug = new RuntimeAdjust.BooleanAdjust( "Toggles frames per second and dirty regions per tick rendering.", "narya.media.fps_display", MediaPrefs.config, false); /** The name of the high-performance timer class we attempt to load. */ protected static final String[] PERF_TIMERS = { "com.threerings.media.timer.PerfTimer", "com.threerings.media.timer.NanoTimer", }; }