8ed7a8d1c6
This will be used by the new stroke tessellator. All the other tessellators should start chopping and chunking too. That will allow us to quit cropping paths if we are afraid they might need more segments than are supported. Bug: chromium:1172543 Change-Id: I30f0ebb581f56cac099d8c05e0e181c4657c3db8 Reviewed-on: https://skia-review.googlesource.com/c/skia/+/390096 Commit-Queue: Chris Dalton <csmartdalton@google.com> Reviewed-by: Robert Phillips <robertphillips@google.com>
491 lines
21 KiB
C++
491 lines
21 KiB
C++
/*
|
|
* Copyright 2020 Google Inc.
|
|
*
|
|
* Use of this source code is governed by a BSD-style license that can be
|
|
* found in the LICENSE file.
|
|
*/
|
|
|
|
#include "tests/Test.h"
|
|
|
|
#include "include/private/SkFloatingPoint.h"
|
|
#include "src/core/SkGeometry.h"
|
|
#include "src/gpu/geometry/GrPathUtils.h"
|
|
#include "src/gpu/mock/GrMockOpTarget.h"
|
|
#include "src/gpu/tessellate/GrStrokeIndirectTessellator.h"
|
|
#include "src/gpu/tessellate/GrStrokeTessellateShader.h"
|
|
#include "src/gpu/tessellate/GrTessellationPathRenderer.h"
|
|
#include "src/gpu/tessellate/GrWangsFormula.h"
|
|
|
|
static sk_sp<GrDirectContext> make_mock_context() {
|
|
GrMockOptions mockOptions;
|
|
mockOptions.fDrawInstancedSupport = true;
|
|
mockOptions.fMaxTessellationSegments = 64;
|
|
mockOptions.fMapBufferFlags = GrCaps::kCanMap_MapFlag;
|
|
mockOptions.fConfigOptions[(int)GrColorType::kAlpha_8].fRenderability =
|
|
GrMockOptions::ConfigOptions::Renderability::kMSAA;
|
|
mockOptions.fConfigOptions[(int)GrColorType::kAlpha_8].fTexturable = true;
|
|
mockOptions.fIntegerSupport = true;
|
|
|
|
GrContextOptions ctxOptions;
|
|
ctxOptions.fGpuPathRenderers = GpuPathRenderers::kTessellation;
|
|
|
|
return GrDirectContext::MakeMock(&mockOptions, ctxOptions);
|
|
}
|
|
|
|
static void test_stroke(skiatest::Reporter* r, GrDirectContext* ctx, GrMockOpTarget* target,
|
|
const SkPath& path, SkRandom& rand) {
|
|
SkStrokeRec stroke(SkStrokeRec::kFill_InitStyle);
|
|
stroke.setStrokeStyle(.1f);
|
|
for (auto join : {SkPaint::kMiter_Join, SkPaint::kRound_Join}) {
|
|
stroke.setStrokeParams(SkPaint::kButt_Cap, join, 4);
|
|
for (int i = 0; i < 16; ++i) {
|
|
float scale = ldexpf(rand.nextF() + 1, i);
|
|
auto matrix = SkMatrix::Scale(scale, scale);
|
|
GrStrokeTessellator::PathStrokeList pathStrokeList(path, stroke, SK_PMColor4fWHITE);
|
|
GrStrokeIndirectTessellator tessellator(GrStrokeTessellateShader::ShaderFlags::kNone,
|
|
matrix, &pathStrokeList, path.countVerbs(),
|
|
target->allocator());
|
|
tessellator.verifyResolveLevels(r, target, matrix, path, stroke);
|
|
tessellator.prepare(target, matrix, path.countVerbs());
|
|
tessellator.verifyBuffers(r, target, matrix, stroke);
|
|
}
|
|
}
|
|
}
|
|
|
|
DEF_TEST(tessellate_GrStrokeIndirectTessellator, r) {
|
|
auto ctx = make_mock_context();
|
|
auto target = std::make_unique<GrMockOpTarget>(ctx);
|
|
SkRandom rand;
|
|
|
|
// Empty strokes.
|
|
SkPath path = SkPath();
|
|
test_stroke(r, ctx.get(), target.get(), path, rand);
|
|
path.moveTo(1,1);
|
|
test_stroke(r, ctx.get(), target.get(), path, rand);
|
|
path.moveTo(1,1);
|
|
test_stroke(r, ctx.get(), target.get(), path, rand);
|
|
path.close();
|
|
test_stroke(r, ctx.get(), target.get(), path, rand);
|
|
path.moveTo(1,1);
|
|
test_stroke(r, ctx.get(), target.get(), path, rand);
|
|
|
|
// Single line.
|
|
path = SkPath().lineTo(1,1);
|
|
test_stroke(r, ctx.get(), target.get(), path, rand);
|
|
path.close();
|
|
test_stroke(r, ctx.get(), target.get(), path, rand);
|
|
|
|
// Single quad.
|
|
path = SkPath().quadTo(1,0,1,1);
|
|
test_stroke(r, ctx.get(), target.get(), path, rand);
|
|
path.close();
|
|
test_stroke(r, ctx.get(), target.get(), path, rand);
|
|
|
|
// Single cubic.
|
|
path = SkPath().cubicTo(1,0,0,1,1,1);
|
|
test_stroke(r, ctx.get(), target.get(), path, rand);
|
|
path.close();
|
|
test_stroke(r, ctx.get(), target.get(), path, rand);
|
|
|
|
// All types of lines.
|
|
path.reset();
|
|
for (int i = 0; i < (1 << 4); ++i) {
|
|
path.moveTo((i>>0)&1, (i>>1)&1);
|
|
path.lineTo((i>>2)&1, (i>>3)&1);
|
|
path.close();
|
|
}
|
|
test_stroke(r, ctx.get(), target.get(), path, rand);
|
|
|
|
// All types of quads.
|
|
path.reset();
|
|
for (int i = 0; i < (1 << 6); ++i) {
|
|
path.moveTo((i>>0)&1, (i>>1)&1);
|
|
path.quadTo((i>>2)&1, (i>>3)&1, (i>>4)&1, (i>>5)&1);
|
|
path.close();
|
|
}
|
|
test_stroke(r, ctx.get(), target.get(), path, rand);
|
|
|
|
// All types of cubics.
|
|
path.reset();
|
|
for (int i = 0; i < (1 << 8); ++i) {
|
|
path.moveTo((i>>0)&1, (i>>1)&1);
|
|
path.cubicTo((i>>2)&1, (i>>3)&1, (i>>4)&1, (i>>5)&1, (i>>6)&1, (i>>7)&1);
|
|
path.close();
|
|
}
|
|
test_stroke(r, ctx.get(), target.get(), path, rand);
|
|
|
|
{
|
|
// This cubic has a convex-180 chop at T=1-"epsilon"
|
|
static const uint32_t hexPts[] = {0x3ee0ac74, 0x3f1e061a, 0x3e0fc408, 0x3f457230,
|
|
0x3f42ac7c, 0x3f70d76c, 0x3f4e6520, 0x3f6acafa};
|
|
SkPoint pts[4];
|
|
memcpy(pts, hexPts, sizeof(pts));
|
|
test_stroke(r, ctx.get(), target.get(),
|
|
SkPath().moveTo(pts[0]).cubicTo(pts[1], pts[2], pts[3]).close(), rand);
|
|
}
|
|
|
|
// Random paths.
|
|
for (int j = 0; j < 50; ++j) {
|
|
path.reset();
|
|
// Empty contours behave differently if closed.
|
|
path.moveTo(0,0);
|
|
path.moveTo(0,0);
|
|
path.close();
|
|
path.moveTo(0,0);
|
|
SkPoint startPoint = {rand.nextF(), rand.nextF()};
|
|
path.moveTo(startPoint);
|
|
// Degenerate curves get skipped.
|
|
path.lineTo(startPoint);
|
|
path.quadTo(startPoint, startPoint);
|
|
path.cubicTo(startPoint, startPoint, startPoint);
|
|
for (int i = 0; i < 100; ++i) {
|
|
switch (rand.nextRangeU(0, 4)) {
|
|
case 0:
|
|
path.lineTo(rand.nextF(), rand.nextF());
|
|
break;
|
|
case 1:
|
|
path.quadTo(rand.nextF(), rand.nextF(), rand.nextF(), rand.nextF());
|
|
break;
|
|
case 2:
|
|
case 3:
|
|
case 4:
|
|
path.cubicTo(rand.nextF(), rand.nextF(), rand.nextF(), rand.nextF(),
|
|
rand.nextF(), rand.nextF());
|
|
break;
|
|
default:
|
|
SkUNREACHABLE;
|
|
}
|
|
if (i % 19 == 0) {
|
|
switch (i/19 % 4) {
|
|
case 0:
|
|
break;
|
|
case 1:
|
|
path.lineTo(startPoint);
|
|
break;
|
|
case 2:
|
|
path.quadTo(SkPoint::Make(1.1f, 1.1f), startPoint);
|
|
break;
|
|
case 3:
|
|
path.cubicTo(SkPoint::Make(1.1f, 1.1f), SkPoint::Make(1.1f, 1.1f),
|
|
startPoint);
|
|
break;
|
|
}
|
|
path.close();
|
|
if (rand.nextU() & 1) { // Implicit or explicit move?
|
|
startPoint = {rand.nextF(), rand.nextF()};
|
|
path.moveTo(startPoint);
|
|
}
|
|
}
|
|
}
|
|
test_stroke(r, ctx.get(), target.get(), path, rand);
|
|
}
|
|
}
|
|
|
|
// Returns the control point for the first/final join of a contour.
|
|
// If the contour is not closed, returns the start point.
|
|
static SkPoint get_contour_closing_control_point(SkPathPriv::RangeIter iter,
|
|
const SkPathPriv::RangeIter& end) {
|
|
auto [verb, p, w] = *iter;
|
|
SkASSERT(verb == SkPathVerb::kMove);
|
|
// Peek ahead to find the last control point.
|
|
SkPoint startPoint=p[0], lastControlPoint=p[0];
|
|
for (++iter; iter != end; ++iter) {
|
|
auto [verb, p, w] = *iter;
|
|
switch (verb) {
|
|
case SkPathVerb::kMove:
|
|
return startPoint;
|
|
case SkPathVerb::kCubic:
|
|
if (p[2] != p[3]) {
|
|
lastControlPoint = p[2];
|
|
break;
|
|
}
|
|
[[fallthrough]];
|
|
case SkPathVerb::kQuad:
|
|
if (p[1] != p[2]) {
|
|
lastControlPoint = p[1];
|
|
break;
|
|
}
|
|
[[fallthrough]];
|
|
case SkPathVerb::kLine:
|
|
if (p[0] != p[1]) {
|
|
lastControlPoint = p[0];
|
|
}
|
|
break;
|
|
case SkPathVerb::kConic:
|
|
SkUNREACHABLE;
|
|
case SkPathVerb::kClose:
|
|
return (p[0] == startPoint) ? lastControlPoint : p[0];
|
|
}
|
|
}
|
|
return startPoint;
|
|
}
|
|
|
|
static bool check_resolve_level(skiatest::Reporter* r, float numCombinedSegments,
|
|
int8_t actualLevel, float tolerance, bool printError = true) {
|
|
int8_t expectedLevel = sk_float_nextlog2(numCombinedSegments);
|
|
if ((actualLevel > expectedLevel &&
|
|
actualLevel > sk_float_nextlog2(numCombinedSegments + tolerance)) ||
|
|
(actualLevel < expectedLevel &&
|
|
actualLevel < sk_float_nextlog2(numCombinedSegments - tolerance))) {
|
|
if (printError) {
|
|
ERRORF(r, "expected %f segments => resolveLevel=%i (got %i)\n",
|
|
numCombinedSegments, expectedLevel, actualLevel);
|
|
}
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
static bool check_first_resolve_levels(skiatest::Reporter* r,
|
|
const SkTArray<float>& firstNumSegments,
|
|
int8_t** nextResolveLevel, float tolerance) {
|
|
for (float numSegments : firstNumSegments) {
|
|
if (numSegments < 0) {
|
|
int8_t val = *(*nextResolveLevel)++;
|
|
REPORTER_ASSERT(r, val == (int)numSegments);
|
|
continue;
|
|
}
|
|
// The first stroke's resolve levels aren't written out until the end of
|
|
// the contour.
|
|
if (!check_resolve_level(r, numSegments, *(*nextResolveLevel)++, tolerance)) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
static float test_tolerance(SkPaint::Join joinType) {
|
|
// Ensure our fast approximation falls within 1.15 tessellation segments of the "correct"
|
|
// answer. This is more than good enough when our matrix scale can go up to 2^17.
|
|
float tolerance = 1.15f;
|
|
if (joinType == SkPaint::kRound_Join) {
|
|
// We approximate two different angles when there are round joins. Double the tolerance.
|
|
tolerance *= 2;
|
|
}
|
|
return tolerance;
|
|
}
|
|
|
|
void GrStrokeIndirectTessellator::verifyResolveLevels(skiatest::Reporter* r,
|
|
GrMockOpTarget* target,
|
|
const SkMatrix& viewMatrix,
|
|
const SkPath& path,
|
|
const SkStrokeRec& stroke) {
|
|
auto tolerances = GrStrokeTolerances::MakeNonHairline(viewMatrix.getMaxScale(),
|
|
stroke.getWidth());
|
|
int8_t resolveLevelForCircles = SkTPin<float>(
|
|
sk_float_nextlog2(tolerances.fNumRadialSegmentsPerRadian * SK_ScalarPI),
|
|
1, kMaxResolveLevel);
|
|
float tolerance = test_tolerance(stroke.getJoin());
|
|
int8_t* nextResolveLevel = fResolveLevels;
|
|
auto iterate = SkPathPriv::Iterate(path);
|
|
SkSTArray<3, float> firstNumSegments;
|
|
bool isFirstStroke = true;
|
|
SkPoint startPoint = {0,0};
|
|
SkPoint lastControlPoint;
|
|
for (auto iter = iterate.begin(); iter != iterate.end(); ++iter) {
|
|
auto [verb, pts, w] = *iter;
|
|
switch (verb) {
|
|
int n;
|
|
SkPoint chops[10];
|
|
case SkPathVerb::kMove:
|
|
startPoint = pts[0];
|
|
lastControlPoint = get_contour_closing_control_point(iter, iterate.end());
|
|
if (!check_first_resolve_levels(r, firstNumSegments, &nextResolveLevel,
|
|
tolerance)) {
|
|
return;
|
|
}
|
|
firstNumSegments.reset();
|
|
isFirstStroke = true;
|
|
break;
|
|
case SkPathVerb::kLine:
|
|
if (pts[0] == pts[1]) {
|
|
break;
|
|
}
|
|
if (stroke.getJoin() == SkPaint::kRound_Join) {
|
|
float rotation = SkMeasureAngleBetweenVectors(pts[0] - lastControlPoint,
|
|
pts[1] - pts[0]);
|
|
float numSegments = rotation * tolerances.fNumRadialSegmentsPerRadian;
|
|
if (isFirstStroke) {
|
|
firstNumSegments.push_back(numSegments);
|
|
} else if (!check_resolve_level(r, numSegments, *nextResolveLevel++,
|
|
tolerance)) {
|
|
return;
|
|
}
|
|
}
|
|
lastControlPoint = pts[0];
|
|
isFirstStroke = false;
|
|
break;
|
|
case SkPathVerb::kQuad: {
|
|
if (pts[0] == pts[1] && pts[1] == pts[2]) {
|
|
break;
|
|
}
|
|
SkVector a = pts[1] - pts[0];
|
|
SkVector b = pts[2] - pts[1];
|
|
bool hasCusp = (a.cross(b) == 0 && a.dot(b) < 0);
|
|
if (hasCusp) {
|
|
// The quad has a cusp. Make sure we wrote out a -resolveLevelForCircles.
|
|
if (isFirstStroke) {
|
|
firstNumSegments.push_back(-resolveLevelForCircles);
|
|
} else {
|
|
REPORTER_ASSERT(r, *nextResolveLevel++ == -resolveLevelForCircles);
|
|
}
|
|
}
|
|
float numParametricSegments = (hasCusp) ? 0 : GrWangsFormula::quadratic(
|
|
tolerances.fParametricIntolerance, pts);
|
|
float rotation = (hasCusp) ? 0 : SkMeasureQuadRotation(pts);
|
|
if (stroke.getJoin() == SkPaint::kRound_Join) {
|
|
SkVector controlPoint = (pts[0] == pts[1]) ? pts[2] : pts[1];
|
|
rotation += SkMeasureAngleBetweenVectors(pts[0] - lastControlPoint,
|
|
controlPoint - pts[0]);
|
|
}
|
|
float numRadialSegments = rotation * tolerances.fNumRadialSegmentsPerRadian;
|
|
float numSegments = numParametricSegments + numRadialSegments;
|
|
if (!hasCusp || stroke.getJoin() == SkPaint::kRound_Join) {
|
|
if (isFirstStroke) {
|
|
firstNumSegments.push_back(numSegments);
|
|
} else if (!check_resolve_level(r, numSegments, *nextResolveLevel++,
|
|
tolerance)) {
|
|
return;
|
|
}
|
|
}
|
|
lastControlPoint = (pts[2] == pts[1]) ? pts[0] : pts[1];
|
|
isFirstStroke = false;
|
|
break;
|
|
}
|
|
case SkPathVerb::kCubic: {
|
|
if (pts[0] == pts[1] && pts[1] == pts[2] && pts[2] == pts[3]) {
|
|
break;
|
|
}
|
|
float T[2];
|
|
bool areCusps = false;
|
|
n = GrPathUtils::findCubicConvex180Chops(pts, T, &areCusps);
|
|
SkChopCubicAt(pts, chops, T, n);
|
|
if (n > 0) {
|
|
int cuspResolveLevel = (areCusps) ? resolveLevelForCircles : 0;
|
|
int signal = -((n << 4) | cuspResolveLevel);
|
|
if (isFirstStroke) {
|
|
firstNumSegments.push_back((float)signal);
|
|
} else {
|
|
REPORTER_ASSERT(r, *nextResolveLevel++ == signal);
|
|
}
|
|
}
|
|
for (int i = 0; i <= n; ++i) {
|
|
// Find the number of segments with our unoptimized approach and make sure
|
|
// it matches the answer we got already.
|
|
SkPoint* p = chops + i*3;
|
|
float numParametricSegments =
|
|
GrWangsFormula::cubic(tolerances.fParametricIntolerance, p);
|
|
SkVector tan0 =
|
|
((p[0] == p[1]) ? (p[1] == p[2]) ? p[3] : p[2] : p[1]) - p[0];
|
|
SkVector tan1 =
|
|
p[3] - ((p[3] == p[2]) ? (p[2] == p[1]) ? p[0] : p[1] : p[2]);
|
|
float rotation = SkMeasureAngleBetweenVectors(tan0, tan1);
|
|
if (i == 0 && stroke.getJoin() == SkPaint::kRound_Join) {
|
|
rotation += SkMeasureAngleBetweenVectors(p[0] - lastControlPoint, tan0);
|
|
}
|
|
float numRadialSegments = rotation * tolerances.fNumRadialSegmentsPerRadian;
|
|
float numSegments = numParametricSegments + numRadialSegments;
|
|
if (isFirstStroke) {
|
|
firstNumSegments.push_back(numSegments);
|
|
} else if (!check_resolve_level(r, numSegments, *nextResolveLevel++,
|
|
tolerance)) {
|
|
return;
|
|
}
|
|
}
|
|
lastControlPoint =
|
|
(pts[3] == pts[2]) ? (pts[2] == pts[1]) ? pts[0] : pts[1] : pts[2];
|
|
isFirstStroke = false;
|
|
break;
|
|
}
|
|
case SkPathVerb::kConic:
|
|
SkUNREACHABLE;
|
|
case SkPathVerb::kClose:
|
|
if (pts[0] != startPoint) {
|
|
SkASSERT(!isFirstStroke);
|
|
if (stroke.getJoin() == SkPaint::kRound_Join) {
|
|
// Line from pts[0] to startPoint, with a preceding join.
|
|
float rotation = SkMeasureAngleBetweenVectors(pts[0] - lastControlPoint,
|
|
startPoint - pts[0]);
|
|
if (!check_resolve_level(
|
|
r, rotation * tolerances.fNumRadialSegmentsPerRadian,
|
|
*nextResolveLevel++, tolerance)) {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
if (!check_first_resolve_levels(r, firstNumSegments, &nextResolveLevel,
|
|
tolerance)) {
|
|
return;
|
|
}
|
|
firstNumSegments.reset();
|
|
isFirstStroke = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!check_first_resolve_levels(r, firstNumSegments, &nextResolveLevel, tolerance)) {
|
|
return;
|
|
}
|
|
firstNumSegments.reset();
|
|
SkASSERT(nextResolveLevel == fResolveLevels + fResolveLevelArrayCount);
|
|
}
|
|
|
|
void GrStrokeIndirectTessellator::verifyBuffers(skiatest::Reporter* r, GrMockOpTarget* target,
|
|
const SkMatrix& viewMatrix,
|
|
const SkStrokeRec& stroke) {
|
|
// Make sure the resolve level we assigned to each instance agrees with the actual data.
|
|
struct IndirectInstance {
|
|
SkPoint fPts[4];
|
|
SkPoint fLastControlPoint;
|
|
float fNumTotalEdges;
|
|
};
|
|
auto instance = static_cast<const IndirectInstance*>(target->peekStaticVertexData());
|
|
auto* indirect = static_cast<const GrDrawIndirectCommand*>(target->peekStaticIndirectData());
|
|
auto tolerances = GrStrokeTolerances::MakeNonHairline(viewMatrix.getMaxScale(),
|
|
stroke.getWidth());
|
|
float tolerance = test_tolerance(stroke.getJoin());
|
|
for (int i = 0; i < fChainedDrawIndirectCount; ++i) {
|
|
int numExtraEdgesInJoin = (stroke.getJoin() == SkPaint::kMiter_Join) ? 4 : 3;
|
|
int numStrokeEdges = indirect->fVertexCount/2 - numExtraEdgesInJoin;
|
|
int numSegments = numStrokeEdges - 1;
|
|
bool isPow2 = !(numSegments & (numSegments - 1));
|
|
REPORTER_ASSERT(r, isPow2);
|
|
int resolveLevel = sk_float_nextlog2(numSegments);
|
|
REPORTER_ASSERT(r, 1 << resolveLevel == numSegments);
|
|
for (unsigned j = 0; j < indirect->fInstanceCount; ++j) {
|
|
SkASSERT(fabsf(instance->fNumTotalEdges) == indirect->fVertexCount/2);
|
|
const SkPoint* p = instance->fPts;
|
|
float numParametricSegments = GrWangsFormula::cubic(
|
|
tolerances.fParametricIntolerance, p);
|
|
float alternateNumParametricSegments = numParametricSegments;
|
|
if (p[0] == p[1] && p[2] == p[3]) {
|
|
// We articulate lines as "p0,p0,p1,p1". This one might actually expect 0 parametric
|
|
// segments.
|
|
alternateNumParametricSegments = 0;
|
|
}
|
|
SkVector tan0 = ((p[0] == p[1]) ? (p[1] == p[2]) ? p[3] : p[2] : p[1]) - p[0];
|
|
SkVector tan1 = p[3] - ((p[3] == p[2]) ? (p[2] == p[1]) ? p[0] : p[1] : p[2]);
|
|
float rotation = SkMeasureAngleBetweenVectors(tan0, tan1);
|
|
// Negative fNumTotalEdges means the curve is a chop, and chops always get treated as a
|
|
// bevel join.
|
|
if (stroke.getJoin() == SkPaint::kRound_Join && instance->fNumTotalEdges > 0) {
|
|
SkVector lastTangent = p[0] - instance->fLastControlPoint;
|
|
rotation += SkMeasureAngleBetweenVectors(lastTangent, tan0);
|
|
}
|
|
// Degenerate strokes are a special case that actually mean the GPU should draw a cusp
|
|
// (i.e. circle).
|
|
if (p[0] == p[1] && p[1] == p[2] && p[2] == p[3]) {
|
|
rotation = SK_ScalarPI;
|
|
}
|
|
float numRadialSegments = rotation * tolerances.fNumRadialSegmentsPerRadian;
|
|
float numSegments = numParametricSegments + numRadialSegments;
|
|
float alternateNumSegments = alternateNumParametricSegments + numRadialSegments;
|
|
if (!check_resolve_level(r, numSegments, resolveLevel, tolerance, false) &&
|
|
!check_resolve_level(r, alternateNumSegments, resolveLevel, tolerance, true)) {
|
|
return;
|
|
}
|
|
++instance;
|
|
}
|
|
++indirect;
|
|
}
|
|
}
|