render/geometry.cpp
| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | // SPDX-License-Identifier: BSD-3-Clause | ||
| 2 | /// @file geometry.cpp | ||
| 3 | /// @brief Following the scene's gprims: meshes uploaded as triangles, implicit shapes placed on | ||
| 4 | /// shared unit shapes. | ||
| 5 | #include "ernie/render/geometry.h" | ||
| 6 | |||
| 7 | #include "ernie/geometry/triangulation.h" | ||
| 8 | #include "ernie/scene/convert.h" | ||
| 9 | #include "ernie/scene/schema/geom.h" | ||
| 10 | #include "ernie/scene/schema/mesh.h" | ||
| 11 | #include "ernie/scene/schema/planet.h" | ||
| 12 | |||
| 13 | #include <glm/gtc/matrix_transform.hpp> | ||
| 14 | |||
| 15 | #include <array> | ||
| 16 | #include <stdexcept> | ||
| 17 | #include <string> | ||
| 18 | |||
| 19 | namespace ernie { | ||
| 20 | namespace { | ||
| 21 | |||
| 22 | /// Metres from a ground plane's centre to its edges: far past any horizon a camera near it sees. | ||
| 23 | constexpr double kGroundHalfWidth = 1e5; | ||
| 24 | |||
| 25 | 117 | std::optional<GeometryKind> FindGeometryKind(const Scene& scene, PrimId prim) { | |
| 26 | 117 | const SchemaDef* schema = scene.Schema(prim); | |
| 27 | 117 | const std::array<std::pair<const SchemaDef*, GeometryKind>, 8> kinds{{ | |
| 28 | 117 | {&Mesh::Definition(), GeometryKind::Triangles}, | |
| 29 | 117 | {&Sphere::Definition(), GeometryKind::Sphere}, | |
| 30 | 117 | {&Cube::Definition(), GeometryKind::Cube}, | |
| 31 | 117 | {&Cylinder::Definition(), GeometryKind::Cylinder}, | |
| 32 | 117 | {&Cone::Definition(), GeometryKind::Cone}, | |
| 33 | 117 | {&Capsule::Definition(), GeometryKind::Capsule}, | |
| 34 | 117 | {&Plane::Definition(), GeometryKind::Plane}, | |
| 35 | 117 | {&GroundPlane::Definition(), GeometryKind::Plane}, | |
| 36 | 117 | }}; | |
| 37 | 802 | for (const auto& [definition, kind] : kinds) { | |
| 38 | 726 | if (IsA(schema, *definition)) { | |
| 39 | 41 | return kind; | |
| 40 | } | ||
| 41 | } | ||
| 42 | 76 | return std::nullopt; | |
| 43 | } | ||
| 44 | |||
| 45 | 41 | double RequireNumber(const Scene& scene, PrimId prim, std::string_view attribute) { | |
| 46 | 41 | const std::optional<Value> value = scene.GetOrFallback(prim, attribute); | |
| 47 | 41 | const std::optional<double> number = value ? ToNumber(*value) : std::nullopt; | |
| 48 | 41 | if (!number) { | |
| 49 | ✗ | throw std::invalid_argument("'" + scene.PrimPath(prim).String() + "' has no number " + | |
| 50 | ✗ | std::string(attribute)); | |
| 51 | } | ||
| 52 | 41 | return *number; | |
| 53 | 41 | } | |
| 54 | |||
| 55 | /// @return The prim's display colour, authored in double or float precision. | ||
| 56 | 41 | glm::vec3 DisplayColour(const Scene& scene, PrimId prim) { | |
| 57 | 41 | const std::optional<Value> value = scene.GetOrFallback(prim, Gprim::kDisplayColor); | |
| 58 | 41 | if (value) { | |
| 59 | 41 | if (const auto* colour = value->Get<glm::dvec3>()) { | |
| 60 | 41 | return glm::vec3(*colour); | |
| 61 | } | ||
| 62 | ✗ | if (const auto* colour = value->Get<glm::vec3>()) { | |
| 63 | ✗ | return *colour; | |
| 64 | } | ||
| 65 | } | ||
| 66 | ✗ | return glm::vec3(0.5f); | |
| 67 | 41 | } | |
| 68 | |||
| 69 | /// @return The turn taking a unit shape's +z to the prim's axis, cyclically, keeping handedness. | ||
| 70 | 8 | glm::dmat4 AxisTurn(const Scene& scene, PrimId prim) { | |
| 71 | 8 | const std::optional<Value> axis = scene.GetOrFallback(prim, Cylinder::kAxis); | |
| 72 | 8 | const auto* name = axis ? axis->Get<std::string>() : nullptr; | |
| 73 | 8 | const char letter = name != nullptr && !name->empty() ? name->front() : 'Z'; | |
| 74 | 8 | glm::dmat4 turn(0.0); | |
| 75 | 8 | turn[3][3] = 1.0; | |
| 76 | // Columns are the images of the unit x, y and z axes. | ||
| 77 | 8 | const int z = letter == 'X' ? 0 : letter == 'Y' ? 1 : 2; | |
| 78 | 8 | turn[0][(z + 1) % 3] = 1.0; | |
| 79 | 8 | turn[1][(z + 2) % 3] = 1.0; | |
| 80 | 8 | turn[2][z] = 1.0; | |
| 81 | 8 | return turn; | |
| 82 | 8 | } | |
| 83 | |||
| 84 | /// @return The transform from a kind's unit shape to the prim's shape. | ||
| 85 | 41 | glm::dmat4 ShapeTransform(const Scene& scene, PrimId prim, GeometryKind kind) { | |
| 86 | 73 | const auto scaled = [](const glm::dvec3& halves) { | |
| 87 | 32 | return glm::scale(glm::dmat4(1.0), halves); | |
| 88 | }; | ||
| 89 | 41 | switch (kind) { | |
| 90 | 21 | case GeometryKind::Sphere: | |
| 91 | 21 | return scaled(glm::dvec3(RequireNumber(scene, prim, Sphere::kRadius))); | |
| 92 | 1 | case GeometryKind::Cube: | |
| 93 | 1 | return scaled(glm::dvec3(0.5 * RequireNumber(scene, prim, Cube::kSize))); | |
| 94 | 2 | case GeometryKind::Cylinder: | |
| 95 | 2 | case GeometryKind::Cone: { | |
| 96 | 2 | const double radius = RequireNumber(scene, prim, Cylinder::kRadius); | |
| 97 | 2 | return AxisTurn(scene, prim) * | |
| 98 | 4 | scaled({radius, radius, 0.5 * RequireNumber(scene, prim, Cylinder::kHeight)}); | |
| 99 | } | ||
| 100 | 3 | case GeometryKind::Capsule: | |
| 101 | 3 | return AxisTurn(scene, prim) * | |
| 102 | 6 | scaled(glm::dvec3(RequireNumber(scene, prim, Capsule::kRadius))); | |
| 103 | 5 | case GeometryKind::Plane: { | |
| 104 | 5 | if (IsA(scene.Schema(prim), GroundPlane::Definition())) { | |
| 105 | 2 | return scaled({kGroundHalfWidth, kGroundHalfWidth, 1.0}); | |
| 106 | } | ||
| 107 | // Width along x, or z for an X normal; length along y, or z for a Y normal: under the | ||
| 108 | // cyclic turn, the unit square's x carries the length unless the normal is z. | ||
| 109 | 3 | const double width = 0.5 * RequireNumber(scene, prim, Plane::kWidth); | |
| 110 | 3 | const double length = 0.5 * RequireNumber(scene, prim, Plane::kLength); | |
| 111 | 3 | const glm::dmat4 turn = AxisTurn(scene, prim); | |
| 112 | 3 | const bool normalZ = turn[2][2] == 1.0; | |
| 113 | 3 | return turn * | |
| 114 | 3 | scaled(normalZ ? glm::dvec3(width, length, 1.0) : glm::dvec3(length, width, 1.0)); | |
| 115 | } | ||
| 116 | case GeometryKind::Triangles: | ||
| 117 | break; | ||
| 118 | } | ||
| 119 | 9 | return glm::dmat4(1.0); | |
| 120 | } | ||
| 121 | |||
| 122 | /// What a changed attribute does to a gprim: reshapes it, moves a mesh's points, remakes its | ||
| 123 | /// topology, or recolours it. | ||
| 124 | struct Touched { | ||
| 125 | bool shape = false; | ||
| 126 | bool points = false; | ||
| 127 | bool topology = false; | ||
| 128 | bool colour = false; | ||
| 129 | |||
| 130 | 105 | bool Any() const { | |
| 131 | 105 | return shape || points || topology || colour; | |
| 132 | } | ||
| 133 | }; | ||
| 134 | |||
| 135 | 105 | Touched ClassifyAttribute(std::string_view attribute) { | |
| 136 | 105 | if (attribute == Gprim::kDisplayColor) { | |
| 137 | 3 | return {.colour = true}; | |
| 138 | } | ||
| 139 | 102 | if (attribute == Mesh::kPoints) { | |
| 140 | 6 | return {.points = true}; | |
| 141 | } | ||
| 142 | 96 | if (attribute == Mesh::kFaceVertexCounts || attribute == Mesh::kFaceVertexIndices) { | |
| 143 | 12 | return {.topology = true}; | |
| 144 | } | ||
| 145 | 404 | for (const std::string_view name : {Sphere::kRadius, Cube::kSize, Cylinder::kHeight, | |
| 146 | 488 | Cylinder::kAxis, Plane::kWidth, Plane::kLength}) { | |
| 147 | 428 | if (attribute == name) { | |
| 148 | 24 | return {.shape = true}; | |
| 149 | } | ||
| 150 | } | ||
| 151 | 60 | return {}; | |
| 152 | } | ||
| 153 | |||
| 154 | } // namespace | ||
| 155 | |||
| 156 | 17 | GeometryPool::GeometryPool(DeviceContext& context, Arenas& arenas, const Scene& scene) | |
| 157 | 17 | : context_(context), arenas_(arenas), scene_(scene), uploader_(context, context.Main()), | |
| 158 | 17 | unitShapes_(static_cast<std::size_t>(GeometryKind::Plane) + 1), | |
| 159 | 17 | records_(std::make_unique<DeviceColumn>( | |
| 160 | 17 | arenas, "geometry records", DType::UInt32, | |
| 161 | 34 | std::vector<std::int64_t>{sizeof(GeometryRecord) / sizeof(std::uint32_t)}, 0)) {} | |
| 162 | |||
| 163 | 43 | void GeometryPool::Touch(GeometryId geometry) { | |
| 164 | 43 | dirtyRecords_.push_back(geometry); | |
| 165 | 43 | } | |
| 166 | |||
| 167 | 61 | std::optional<PrimGeometry> GeometryPool::Find(PrimId prim) const { | |
| 168 | 61 | if (prim.slot >= prims_.size() || !prims_[prim.slot] || !(prims_[prim.slot]->prim == prim)) { | |
| 169 | 3 | return std::nullopt; | |
| 170 | } | ||
| 171 | 58 | return prims_[prim.slot]; | |
| 172 | } | ||
| 173 | |||
| 174 | 31 | GeometryId GeometryPool::Allocate(const Geometry& geometry) { | |
| 175 | 31 | GeometryId id = 0; | |
| 176 | 31 | if (!unused_.empty()) { | |
| 177 | ✗ | id = unused_.back(); | |
| 178 | ✗ | unused_.pop_back(); | |
| 179 | ✗ | geometries_[id] = geometry; | |
| 180 | } else { | ||
| 181 | 31 | id = static_cast<GeometryId>(geometries_.size()); | |
| 182 | 31 | geometries_.push_back(geometry); | |
| 183 | } | ||
| 184 | 31 | Touch(id); | |
| 185 | 31 | return id; | |
| 186 | } | ||
| 187 | |||
| 188 | 2 | void GeometryPool::Free(GeometryId id, Changes& changes) { | |
| 189 | 2 | Geometry& geometry = geometries_[id]; | |
| 190 | 8 | for (const BufferRange* range : {&geometry.vertices, &geometry.indices, &geometry.box}) { | |
| 191 | 6 | if (range->size > 0) { | |
| 192 | 3 | arenas_.Free(*range); | |
| 193 | } | ||
| 194 | } | ||
| 195 | 2 | geometry = {}; | |
| 196 | 2 | unused_.push_back(id); | |
| 197 | 2 | Touch(id); | |
| 198 | 2 | changes.freed.push_back(id); | |
| 199 | 2 | } | |
| 200 | |||
| 201 | 26 | void GeometryPool::UploadBox(const Geometry& geometry, const glm::vec3& low, | |
| 202 | const glm::vec3& high) { | ||
| 203 | 26 | const std::array<float, 6> box{low.x, low.y, low.z, high.x, high.y, high.z}; | |
| 204 | 26 | uploader_.Write(arenas_.Storage(geometry.box), geometry.box.offset, | |
| 205 | std::span<const float>(box)); | ||
| 206 | 26 | } | |
| 207 | |||
| 208 | 29 | GeometryId GeometryPool::UnitShape(GeometryKind kind) { | |
| 209 | 29 | std::optional<GeometryId>& shared = unitShapes_[static_cast<std::size_t>(kind)]; | |
| 210 | 29 | if (!shared) { | |
| 211 | 23 | Geometry geometry{.kind = kind, .box = arenas_.Allocate(6 * sizeof(float))}; | |
| 212 | // The unit square is flat: its box has no depth. | ||
| 213 | 41 | UploadBox(geometry, glm::vec3(-1, -1, kind == GeometryKind::Plane ? 0 : -1), glm::vec3(1)); | |
| 214 | 23 | shared = Allocate(geometry); | |
| 215 | } | ||
| 216 | 29 | return *shared; | |
| 217 | } | ||
| 218 | |||
| 219 | 8 | void GeometryPool::UploadMesh(PrimId prim, GeometryId id, bool topology, Changes& changes) { | |
| 220 | 8 | const std::string path = scene_.PrimPath(prim).String(); | |
| 221 | 8 | const Tensor* points = scene_.Read(prim, Mesh::kPoints); | |
| 222 | 8 | const std::vector<float> positions = | |
| 223 | 16 | points != nullptr ? CopyFloats(*points, 3, path + "'s points") : std::vector<float>(); | |
| 224 | 8 | const auto vertexCount = static_cast<std::uint32_t>(positions.size() / 3); | |
| 225 | 8 | Geometry& geometry = geometries_[id]; | |
| 226 | 8 | const bool resized = vertexCount != geometry.vertexCount; | |
| 227 | 8 | if (topology || resized) { | |
| 228 | 21 | const auto indices = [&](std::string_view attribute) { | |
| 229 | 14 | const Tensor* tensor = scene_.Read(prim, attribute); | |
| 230 | 70 | return tensor != nullptr ? CopyInts(*tensor, path + "'s " + std::string(attribute)) | |
| 231 | 28 | : std::vector<std::int64_t>(); | |
| 232 | 7 | }; | |
| 233 | 7 | const std::vector<std::uint32_t> triangles = TriangulateFaces( | |
| 234 | 14 | indices(Mesh::kFaceVertexCounts), indices(Mesh::kFaceVertexIndices), vertexCount); | |
| 235 | 21 | for (BufferRange* range : {&geometry.vertices, &geometry.indices}) { | |
| 236 | 14 | if (range->size > 0) { | |
| 237 | 4 | arenas_.Free(*range); | |
| 238 | 4 | *range = {}; | |
| 239 | } | ||
| 240 | } | ||
| 241 | 7 | geometry.vertexCount = vertexCount; | |
| 242 | 7 | geometry.triangleCount = static_cast<std::uint32_t>(triangles.size() / 3); | |
| 243 | 7 | if (!triangles.empty()) { | |
| 244 | 7 | geometry.vertices = arenas_.Allocate(positions.size() * sizeof(float)); | |
| 245 | 7 | geometry.indices = arenas_.Allocate(triangles.size() * sizeof(std::uint32_t)); | |
| 246 | 7 | uploader_.Write(arenas_.Storage(geometry.indices), geometry.indices.offset, | |
| 247 | std::span<const std::uint32_t>(triangles)); | ||
| 248 | } | ||
| 249 | 7 | Touch(id); | |
| 250 | 7 | changes.rebuilt.push_back(id); | |
| 251 | 7 | } else { | |
| 252 | 1 | changes.refitted.push_back(id); | |
| 253 | } | ||
| 254 | 8 | if (geometry.triangleCount > 0) { | |
| 255 | 8 | uploader_.Write(arenas_.Storage(geometry.vertices), geometry.vertices.offset, | |
| 256 | std::span<const float>(positions)); | ||
| 257 | } | ||
| 258 | 8 | } | |
| 259 | |||
| 260 | 117 | void GeometryPool::Place(PrimId prim, bool points, bool topology, Changes& changes) { | |
| 261 | 117 | const std::optional<GeometryKind> kind = FindGeometryKind(scene_, prim); | |
| 262 | 117 | if (!kind) { | |
| 263 | 76 | return; | |
| 264 | } | ||
| 265 | 41 | if (prim.slot >= prims_.size()) { | |
| 266 | 38 | prims_.resize(std::max<std::size_t>(prim.slot + 1, 2 * prims_.size())); | |
| 267 | } | ||
| 268 | 41 | std::optional<PrimGeometry>& placed = prims_[prim.slot]; | |
| 269 | 41 | const bool known = placed && placed->prim == prim; | |
| 270 | 41 | PrimGeometry next{.prim = prim}; | |
| 271 | 41 | if (*kind == GeometryKind::Triangles) { | |
| 272 | 9 | next.geometry = known ? placed->geometry : Allocate({.kind = GeometryKind::Triangles}); | |
| 273 | 9 | if (!known || points || topology) { | |
| 274 | 8 | UploadMesh(prim, next.geometry, topology || !known, changes); | |
| 275 | } | ||
| 276 | 32 | } else if (*kind == GeometryKind::Capsule) { | |
| 277 | 3 | const double radius = RequireNumber(scene_, prim, Capsule::kRadius); | |
| 278 | 3 | const auto halfLength = | |
| 279 | 3 | static_cast<float>(0.5 * RequireNumber(scene_, prim, Capsule::kHeight) / radius); | |
| 280 | 3 | next.geometry = known ? placed->geometry | |
| 281 | 3 | : Allocate({.kind = GeometryKind::Capsule, | |
| 282 | 3 | .box = arenas_.Allocate(6 * sizeof(float))}); | |
| 283 | 3 | Geometry& geometry = geometries_[next.geometry]; | |
| 284 | 3 | if (!known || geometry.halfLength != halfLength) { | |
| 285 | 3 | geometry.halfLength = halfLength; | |
| 286 | 6 | UploadBox(geometry, glm::vec3(-1, -1, -1 - halfLength), | |
| 287 | 3 | glm::vec3(1, 1, 1 + halfLength)); | |
| 288 | 3 | Touch(next.geometry); | |
| 289 | 3 | changes.rebuilt.push_back(next.geometry); | |
| 290 | } | ||
| 291 | } else { | ||
| 292 | 29 | next.geometry = UnitShape(*kind); | |
| 293 | 29 | if (!known) { | |
| 294 | 26 | changes.rebuilt.push_back(next.geometry); | |
| 295 | } | ||
| 296 | } | ||
| 297 | 41 | next.shape = ShapeTransform(scene_, prim, *kind); | |
| 298 | 41 | next.colour = DisplayColour(scene_, prim); | |
| 299 | 41 | if (!known || placed->geometry != next.geometry || placed->shape != next.shape || | |
| 300 | 6 | placed->colour != next.colour) { | |
| 301 | 37 | changes.placed.push_back(next); | |
| 302 | } | ||
| 303 | 41 | placed = next; | |
| 304 | } | ||
| 305 | |||
| 306 | 34 | GeometryPool::Changes GeometryPool::Update() { | |
| 307 | 34 | ERNIE_ZONE("Update geometry"); | |
| 308 | 34 | const PrimStore& store = scene_.Store(); | |
| 309 | 34 | const ChangeSet changeSet = store.ChangesSince(cursor_); | |
| 310 | 34 | cursor_ = changeSet.version; | |
| 311 | 34 | const PrimRows::Changes followed = rows_.Follow(store, changeSet); | |
| 312 | 34 | Changes changes; | |
| 313 | 38 | for (const PrimId prim : followed.removed) { | |
| 314 | 4 | if (const std::optional<PrimGeometry> placed = Find(prim)) { | |
| 315 | 2 | const Geometry& geometry = geometries_[placed->geometry]; | |
| 316 | 2 | if (geometry.kind == GeometryKind::Triangles || | |
| 317 | geometry.kind == GeometryKind::Capsule) { | ||
| 318 | 2 | Free(placed->geometry, changes); | |
| 319 | } | ||
| 320 | 2 | prims_[prim.slot].reset(); | |
| 321 | 2 | changes.removed.push_back(prim); | |
| 322 | } | ||
| 323 | } | ||
| 324 | 144 | for (const PrimId prim : followed.added) { | |
| 325 | 110 | Place(prim, true, true, changes); | |
| 326 | } | ||
| 327 | // Prims just added were placed from all their values: none of them is an edit. | ||
| 328 | 34 | std::vector<bool> fresh(prims_.size(), false); | |
| 329 | 144 | for (const PrimId prim : followed.added) { | |
| 330 | 110 | if (prim.slot < fresh.size()) { | |
| 331 | 40 | fresh[prim.slot] = true; | |
| 332 | } | ||
| 333 | } | ||
| 334 | // Values the host authored: shapes, points, topology and colours. | ||
| 335 | 139 | for (const ChangeSet::Values& values : changeSet.values) { | |
| 336 | 105 | const Touched touched = ClassifyAttribute(store.Tokens().Text(values.attribute)); | |
| 337 | 105 | if (!touched.Any()) { | |
| 338 | 60 | continue; | |
| 339 | } | ||
| 340 | 45 | const Bucket& bucket = store.GetBucket(values.bucket); | |
| 341 | 90 | for (const RowRange& range : values.rows) { | |
| 342 | 93 | for (std::uint32_t row = range.begin; row < std::min(range.end, bucket.Rows()); ++row) { | |
| 343 | 48 | const PrimId prim = bucket.prims[row]; | |
| 344 | 48 | if (Find(prim) && !fresh[prim.slot]) { | |
| 345 | 7 | Place(prim, touched.points, touched.topology, changes); | |
| 346 | } | ||
| 347 | } | ||
| 348 | } | ||
| 349 | } | ||
| 350 | 68 | return changes; | |
| 351 | 68 | } | |
| 352 | |||
| 353 | 34 | void GeometryPool::AddUploads(graph::Graph& graph) { | |
| 354 | 34 | if (!dirtyRecords_.empty()) { | |
| 355 | 18 | hostRecords_.resize(geometries_.size()); | |
| 356 | 18 | records_->Resize(geometries_.size(), context_.Main()); | |
| 357 | 18 | SortUnique(dirtyRecords_); | |
| 358 | 86 | const auto address = [&](const BufferRange& range) { | |
| 359 | 68 | return range.size > 0 ? arenas_.Address(range) : std::uint64_t{0}; | |
| 360 | 18 | }; | |
| 361 | 52 | for (const std::uint32_t id : dirtyRecords_) { | |
| 362 | 34 | const Geometry& geometry = geometries_[id]; | |
| 363 | 34 | hostRecords_[id] = {.kind = static_cast<std::uint32_t>(geometry.kind), | |
| 364 | 34 | .halfLength = geometry.halfLength, | |
| 365 | 34 | .vertexCount = geometry.vertexCount, | |
| 366 | 34 | .triangleCount = geometry.triangleCount, | |
| 367 | 34 | .vertices = {address(geometry.vertices)}, | |
| 368 | 34 | .indices = {address(geometry.indices)}}; | |
| 369 | } | ||
| 370 | 18 | StageRows(uploader_, *records_, hostRecords_, dirtyRecords_); | |
| 371 | 18 | dirtyRecords_.clear(); | |
| 372 | } | ||
| 373 | 34 | if (uploader_.Staged()) { | |
| 374 | 19 | std::vector<graph::Buffer> buffers = Import(graph); | |
| 375 | 19 | graph::AddUploadPass(graph, "Upload geometry", uploader_, buffers); | |
| 376 | 19 | } | |
| 377 | 34 | } | |
| 378 | |||
| 379 | 38 | std::vector<graph::Buffer> GeometryPool::Import(graph::Graph& graph) const { | |
| 380 | 38 | std::vector<graph::Buffer> buffers; | |
| 381 | 76 | for (std::uint32_t arena = 0; arena < arenas_.Count(); ++arena) { | |
| 382 | 38 | const Buffer& buffer = arenas_.Storage(BufferRange{.arena = arena}); | |
| 383 | 76 | buffers.push_back(graph.Import(buffer, 0, buffer.Size(), "geometry")); | |
| 384 | } | ||
| 385 | 38 | return buffers; | |
| 386 | ✗ | } | |
| 387 | |||
| 388 | } // namespace ernie | ||
| 389 |