runelite-api: Optimize getClickbox

- Use the pre-calculated center/extreme xyz fields for the aabb
 - use modelToCanvas and reduce indirection
 - Use a specialized union that only does axis-aligned rectangles
     instead of the Area class
 - Use a specialized intersection that only does convex polygons, again
     to avoid Area
This commit is contained in:
Max Weber
2019-09-09 10:46:42 -06:00
parent 80709f1bfa
commit f16cd53d09
5 changed files with 688 additions and 218 deletions

View File

@@ -103,4 +103,5 @@ public interface Model extends Renderable
int getExtremeZ();
int getXYZMag();
boolean isClickable();
}

View File

@@ -27,20 +27,19 @@ package net.runelite.api;
import java.awt.FontMetrics;
import java.awt.Graphics2D;
import java.awt.Polygon;
import java.awt.Rectangle;
import java.awt.geom.Area;
import java.awt.Shape;
import java.awt.geom.Rectangle2D;
import java.awt.image.BufferedImage;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import static net.runelite.api.Constants.TILE_FLAG_BRIDGE;
import net.runelite.api.coords.LocalPoint;
import net.runelite.api.geometry.RectangleUnion;
import net.runelite.api.geometry.Shapes;
import net.runelite.api.geometry.SimplePolygon;
import net.runelite.api.model.Jarvis;
import net.runelite.api.model.Triangle;
import net.runelite.api.model.Vertex;
import net.runelite.api.widgets.Widget;
import net.runelite.api.widgets.WidgetInfo;
@@ -562,244 +561,178 @@ public class Perspective
* Get the on-screen clickable area of {@code model} as though it's for the
* object on the tile at ({@code localX}, {@code localY}) and rotated to
* angle {@code orientation}.
*
* @param client the game client
* @param model the model to calculate a clickbox for
* @param client the game client
* @param model the model to calculate a clickbox for
* @param orientation the orientation of the model (0-2048, where 0 is north)
* @param point the coordinate of the tile
* @param point the coordinate of the tile
* @return the clickable area of the model
*/
public static @Nullable Area getClickbox(@Nonnull Client client, Model model, int orientation, @Nonnull LocalPoint point)
@Nullable
public static Shape getClickbox(@Nonnull Client client, Model model, int orientation, LocalPoint point)
{
if (model == null)
{
return null;
}
List<Triangle> triangles = model.getTriangles().stream()
.map(triangle -> triangle.rotate(orientation))
.collect(Collectors.toList());
int x = point.getX();
int y = point.getY();
int z = getTileHeight(client, point, client.getPlane());
List<Vertex> vertices = model.getVertices().stream()
.map(v -> v.rotate(orientation))
.collect(Collectors.toList());
SimplePolygon bounds = calculateAABB(client, model, orientation, x, y, z);
Area clickBox = get2DGeometry(client, triangles, point);
Area visibleAABB = getAABB(client, vertices, point);
if (visibleAABB == null)
if (bounds == null)
{
return null;
}
clickBox.intersect(visibleAABB);
return clickBox;
}
/**
* Determine if a given point is off-screen.
*
* @param client
* @param point
* @return
*/
private static boolean isOffscreen(@Nonnull Client client, @Nonnull Point point)
{
return (point.getX() < 0 || point.getX() >= client.getViewportWidth())
&& (point.getY() < 0 || point.getY() >= client.getViewportHeight());
}
private static @Nonnull Area get2DGeometry(
@Nonnull Client client,
@Nonnull List<Triangle> triangles,
@Nonnull LocalPoint point
)
{
int radius = 5;
Area geometry = new Area();
final int tileHeight = getTileHeight(client, point, client.getPlane());
for (Triangle triangle : triangles)
if (model.isClickable())
{
Vertex _a = triangle.getA();
Point a = localToCanvas(client,
point.getX() - _a.getX(),
point.getY() - _a.getZ(),
tileHeight + _a.getY());
if (a == null)
{
continue;
}
Vertex _b = triangle.getB();
Point b = localToCanvas(client,
point.getX() - _b.getX(),
point.getY() - _b.getZ(),
tileHeight + _b.getY());
if (b == null)
{
continue;
}
Vertex _c = triangle.getC();
Point c = localToCanvas(client,
point.getX() - _c.getX(),
point.getY() - _c.getZ(),
tileHeight + _c.getY());
if (c == null)
{
continue;
}
if (isOffscreen(client, a) && isOffscreen(client, b) && isOffscreen(client, c))
{
continue;
}
int minX = Math.min(Math.min(a.getX(), b.getX()), c.getX());
int minY = Math.min(Math.min(a.getY(), b.getY()), c.getY());
// For some reason, this calculation is always 4 pixels short of the actual in-client one
int maxX = Math.max(Math.max(a.getX(), b.getX()), c.getX()) + 4;
int maxY = Math.max(Math.max(a.getY(), b.getY()), c.getY()) + 4;
Rectangle clickableRect = new Rectangle(
minX - radius, minY - radius,
maxX - minX + radius, maxY - minY + radius
);
if (geometry.contains(clickableRect))
{
continue;
}
geometry.add(new Area(clickableRect));
return bounds;
}
return geometry;
}
private static Area getAABB(
@Nonnull Client client,
@Nonnull List<Vertex> vertices,
@Nonnull LocalPoint point
)
{
int maxX = 0;
int minX = 0;
int maxY = 0;
int minY = 0;
int maxZ = 0;
int minZ = 0;
for (Vertex vertex : vertices)
{
int x = vertex.getX();
int y = vertex.getY();
int z = vertex.getZ();
if (x > maxX)
{
maxX = x;
}
if (x < minX)
{
minX = x;
}
if (y > maxY)
{
maxY = y;
}
if (y < minY)
{
minY = y;
}
if (z > maxZ)
{
maxZ = z;
}
if (z < minZ)
{
minZ = z;
}
}
int centerX = (minX + maxX) / 2;
int centerY = (minY + maxY) / 2;
int centerZ = (minZ + maxZ) / 2;
int extremeX = (maxX - minX + 1) / 2;
int extremeY = (maxY - minY + 1) / 2;
int extremeZ = (maxZ - minZ + 1) / 2;
if (extremeX < 32)
{
extremeX = 32;
}
if (extremeZ < 32)
{
extremeZ = 32;
}
int x1 = point.getX() - (centerX - extremeX);
int y1 = centerY - extremeY;
int z1 = point.getY() - (centerZ - extremeZ);
int x2 = point.getX() - (centerX + extremeX);
int y2 = centerY + extremeY;
int z2 = point.getY() - (centerZ + extremeZ);
final int tileHeight = getTileHeight(client, point, client.getPlane());
Point p1 = localToCanvas(client, x1, z1, tileHeight + y1);
Point p2 = localToCanvas(client, x1, z2, tileHeight + y1);
Point p3 = localToCanvas(client, x2, z2, tileHeight + y1);
Point p4 = localToCanvas(client, x2, z1, tileHeight + y1);
Point p5 = localToCanvas(client, x1, z1, tileHeight + y2);
Point p6 = localToCanvas(client, x1, z2, tileHeight + y2);
Point p7 = localToCanvas(client, x2, z2, tileHeight + y2);
Point p8 = localToCanvas(client, x2, z1, tileHeight + y2);
List<Point> points = new ArrayList<>(8);
points.add(p1);
points.add(p2);
points.add(p3);
points.add(p4);
points.add(p5);
points.add(p6);
points.add(p7);
points.add(p8);
try
{
points = Jarvis.convexHull(points);
}
catch (NullPointerException e)
{
// No non-null screen points for this AABB e.g. for an way off-screen model
return null;
}
if (points == null)
Shapes<SimplePolygon> bounds2d = calculate2DBounds(client, model, orientation, x, y, z);
if (bounds2d == null)
{
return null;
}
Polygon hull = new Polygon();
for (Point p : points)
for (SimplePolygon poly : bounds2d.getShapes())
{
if (p != null)
{
hull.addPoint(p.getX(), p.getY());
}
poly.intersectWithConvex(bounds);
}
return new Area(hull);
return bounds2d;
}
private static SimplePolygon calculateAABB(Client client, Model m, int jauOrient, int x, int y, int z)
{
int ex = m.getExtremeX();
if (ex == -1)
{
// dynamic models don't get stored when they render where this normally happens
m.calculateBoundsCylinder();
m.calculateExtreme(0);
ex = m.getExtremeX();
}
int x1 = m.getCenterX();
int y1 = m.getCenterZ();
int z1 = m.getCenterY();
int ey = m.getExtremeZ();
int ez = m.getExtremeY();
int x2 = x1 + ex;
int y2 = y1 + ey;
int z2 = z1 + ez;
x1 -= ex;
y1 -= ey;
z1 -= ez;
int[] xa = new int[]{
x1, x2, x1, x2,
x1, x2, x1, x2
};
int[] ya = new int[]{
y1, y1, y2, y2,
y1, y1, y2, y2
};
int[] za = new int[]{
z1, z1, z1, z1,
z2, z2, z2, z2
};
int[] x2d = new int[8];
int[] y2d = new int[8];
modelToCanvas(client, 8, x, y, z, jauOrient, xa, ya, za, x2d, y2d);
return Jarvis.convexHull(x2d, y2d);
}
private static Shapes<SimplePolygon> calculate2DBounds(Client client, Model m, int jauOrient, int x, int y, int z)
{
int[] x2d = new int[m.getVerticesCount()];
int[] y2d = new int[m.getVerticesCount()];
Perspective.modelToCanvas(client,
m.getVerticesCount(),
x, y, z,
jauOrient,
m.getVerticesX(), m.getVerticesZ(), m.getVerticesY(),
x2d, y2d);
final int radius = 5;
int[][] tris = new int[][]{
m.getTrianglesX(),
m.getTrianglesY(),
m.getTrianglesZ()
};
int vpX1 = client.getViewportXOffset();
int vpY1 = client.getViewportXOffset();
int vpX2 = vpX1 + client.getViewportWidth();
int vpY2 = vpY1 + client.getViewportHeight();
List<RectangleUnion.Rectangle> rects = new ArrayList<>(m.getTrianglesCount());
nextTri:
for (int tri = 0; tri < m.getTrianglesCount(); tri++)
{
int
minX = Integer.MAX_VALUE,
minY = Integer.MAX_VALUE,
maxX = Integer.MIN_VALUE,
maxY = Integer.MIN_VALUE;
for (int[] vertex : tris)
{
final int idx = vertex[tri];
final int xs = x2d[idx];
final int ys = y2d[idx];
if (xs == Integer.MIN_VALUE || ys == Integer.MIN_VALUE)
{
continue nextTri;
}
if (xs < minX)
{
minX = xs;
}
if (xs > maxX)
{
maxX = xs;
}
if (ys < minY)
{
minY = ys;
}
if (ys > maxY)
{
maxY = ys;
}
}
minX -= radius;
minY -= radius;
maxX += radius;
maxY += radius;
if (vpX1 > maxX || vpX2 < minX || vpY1 > maxY || vpY2 < minY)
{
continue;
}
RectangleUnion.Rectangle r = new RectangleUnion.Rectangle(minX, minY, maxX, maxY);
rects.add(r);
}
return RectangleUnion.union(rects);
}
/**

View File

@@ -0,0 +1,415 @@
/*
* Copyright (c) 2019 Abex
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
* ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package net.runelite.api.geometry;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import javax.annotation.Nullable;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor;
import lombok.ToString;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class RectangleUnion
{
private RectangleUnion()
{
}
@RequiredArgsConstructor
@Getter
@ToString
public static class Rectangle
{
private final int x1, y1, x2, y2;
}
/**
* Returns a polygon representing the union of all of the passed rectangles.
* the passed List will be modified
*/
@Nullable
public static Shapes<SimplePolygon> union(List<Rectangle> lefts)
{
// https://stackoverflow.com/a/35362615/2977136
if (lefts.size() == 0)
{
return null;
}
boolean trace = log.isTraceEnabled();
// Sort all of the rectangles so they are ordered by their left edge
lefts.sort(Comparator.comparing(Rectangle::getX1));
// Again, but for the right edge
// this should be relatively fast if the rectangles are similar sizes because timsort deals with partially
// presorted data well
List<Rectangle> rights = new ArrayList<>(lefts);
rights.sort(Comparator.comparing(Rectangle::getX2));
// ranges of our scan line with how many rectangles it is occluding
Segments segments = new Segments();
Shapes<SimplePolygon> out = new Shapes<>(new ArrayList<>());
ChangingState cs = new ChangingState(out);
// Walk a beam left to right, colliding with any vertical edges of rectangles
for (int l = 0, r = 0; ; )
{
Rectangle lr = null, rr = null;
if (l < lefts.size())
{
lr = lefts.get(l);
}
if (r < rights.size())
{
rr = rights.get(r);
}
if (lr == null && rr == null)
{
break;
}
// get the next edge, preferring + edges
Rectangle rect;
boolean remove = lr == null || (rr != null && rr.x2 < lr.x1);
if (remove)
{
cs.delta = -1;
cs.x = rr.x2;
r++;
rect = rr;
}
else
{
cs.delta = 1;
cs.x = lr.x1;
l++;
rect = lr;
}
if (trace)
{
log.trace("{}{}", remove ? "-" : "+", rect);
}
int y1 = rect.y1;
int y2 = rect.y2;
// Find or create the y1 edge
Segment n = segments.findLE(y1);
if (n == null)
{
n = segments.insertAfter(null, y1);
}
if (n.y != y1)
{
n = segments.insertAfter(n, y1);
n.value = n.previous.value;
}
for (; ; )
{
// create the y2 edge if the next edge is past
if (n.next == null || n.next.y > y2)
{
segments.insertAfter(n, y2);
}
cs.touch(n);
n = n.next;
if (n.y == y2)
{
cs.finish(n);
if (trace)
{
for (Segment s = segments.first; s != null; s = s.next)
{
String chunk = "";
if (s.chunk != null)
{
chunk = (s.left ? ">" : "[") + System.identityHashCode(s.chunk) + (s.left ? "]" : "<");
}
log.trace("{} = {} {}", s.y, s.value, chunk);
}
log.trace("");
}
break;
}
}
}
assert segments.allZero();
return out;
}
@RequiredArgsConstructor
private static class ChangingState
{
final Shapes<SimplePolygon> out;
int x;
int delta;
Segment first;
void touch(Segment s)
{
int oldValue = s.value;
s.value += delta;
if (oldValue <= 0 ^ s.value <= 0)
{
if (first == null)
{
first = s;
}
}
else
{
finish(s);
}
}
void finish(Segment s)
{
if (first == null)
{
return;
}
if (first.chunk != null && s.chunk != null)
{
push(first);
push(s);
if (first.chunk == s.chunk)
{
Chunk c = first.chunk;
first.chunk = null;
s.chunk = null;
c.left = null;
c.right = null;
out.getShapes().add(c);
}
else
{
Chunk leftChunk, rightChunk;
if (!s.left)
{
leftChunk = s.chunk;
rightChunk = first.chunk;
}
else
{
leftChunk = first.chunk;
rightChunk = s.chunk;
}
log.trace("Joining {} onto {}", System.identityHashCode(rightChunk), System.identityHashCode(leftChunk));
if (first.left == s.left)
{
log.trace("reverse");
if (first.left)
{
leftChunk.reverse();
}
else
{
rightChunk.reverse();
}
}
log.trace("{} {}", first.y, s.y);
rightChunk.appendTo(leftChunk);
first.chunk = null;
s.chunk = null;
leftChunk.right.chunk = null;
rightChunk.left.chunk = null;
leftChunk.right = rightChunk.right;
leftChunk.left.chunk = leftChunk;
leftChunk.right.chunk = leftChunk;
}
}
else if (first.chunk == null && s.chunk == null)
{
first.chunk = new Chunk();
first.chunk.right = first;
first.left = false;
s.chunk = first.chunk;
first.chunk.left = s;
s.left = true;
push(first);
push(s);
}
else if (first.chunk == null)
{
push(s);
move(first, s);
push(first);
}
else
{
push(first);
move(s, first);
push(s);
}
first = null;
}
private void move(Segment dst, Segment src)
{
dst.chunk = src.chunk;
dst.left = src.left;
src.chunk = null;
if (dst.left)
{
assert dst.chunk.left == src;
dst.chunk.left = dst;
}
else
{
assert dst.chunk.right == src;
dst.chunk.right = dst;
}
}
private void push(Segment s)
{
if (s.left)
{
s.chunk.pushLeft(x, s.y);
assert s.chunk.left == s;
}
else
{
s.chunk.pushRight(x, s.y);
assert s.chunk.right == s;
}
}
}
@NoArgsConstructor
private static class Segment
{
Segment next, previous;
Chunk chunk;
boolean left;
int y;
int value;
}
@NoArgsConstructor
private static class Segments
{
Segment first;
Segment findLE(int y)
{
Segment s = first;
if (s == null || s.y > y)
{
return null;
}
for (; ; )
{
if (s.y == y)
{
return s;
}
Segment n = s.next;
if (n == null || n.y > y)
{
return s;
}
s = n;
}
}
Segment insertAfter(Segment before, int y)
{
Segment n = new Segment();
n.y = y;
if (before != null)
{
if (before.next != null)
{
n.next = before.next;
n.next.previous = n;
}
n.value = before.value;
before.next = n;
n.previous = before;
}
else
{
if (first != null)
{
n.next = first;
first.previous = n;
}
first = n;
}
return n;
}
boolean allZero()
{
for (Segment s = first; s != null; s = s.next)
{
if (s.value != 0 || s.chunk != null)
{
return false;
}
}
return true;
}
}
private static class Chunk extends SimplePolygon
{
Segment left, right;
@Override
public void reverse()
{
super.reverse();
assert right.left == false;
assert left.left == true;
Segment tr = left;
left = right;
right = tr;
right.left = false;
left.left = true;
}
}
}