Files

653 lines
24 KiB
C++

#include "zeroerr/assert.h"
#include "zeroerr/unittest.h"
#include "filesystem/FileData.h"
#include "graphics/AmbientOcclusion.h"
#include "graphics/AntiAliasing.h"
#include "graphics/Canvas.h"
#include "graphics/DrawItem2D.h"
#include "graphics/Font.h"
#include "graphics/GBuffer.h"
#include "graphics/GlobalIllumination.h"
#include "graphics/Graphics.h"
#include "graphics/Grass.h"
#include "graphics/Material.h"
#include "graphics/Outline.h"
#include "graphics/Quad.h"
#include "graphics/ScreenSpaceReflection.h"
#include "graphics/Texture.h"
#include "graphics/Volumetric.h"
#include "graphics/Water.h"
#include "graphics/Waterfall.h"
// Color lives in eve::graphics (see graphics/Canvas.h); keep the unqualified form.
using eve::graphics::Color;
#include "graphics/Light.h"
#include "graphics/Mesh.h"
#include "graphics/RenderControl.h"
#include "graphics/RenderSystem.h"
#include "graphics/RenderSystem3D.h"
#include "graphics/Shader.h"
#include "graphics/shaders/mesh3d_grass_frag_spv.inc"
#include "graphics/shaders/mesh3d_grass_vert_spv.inc"
#include "image/Image.h"
#include "image/ImageData.h"
#include "window/Window.h"
#include <SDL2/SDL.h>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <filesystem>
#include <fstream>
#include <memory>
#include <string>
#include <vector>
using eve::graphics::Camera3D;
using eve::graphics::Graphics;
using eve::graphics::GrassField;
using eve::graphics::Light3D;
using eve::graphics::Mesh;
using eve::graphics::Renderable2D;
using eve::graphics::Renderable3D;
using eve::graphics::RenderSystem;
using eve::graphics::RenderSystem3D;
using eve::graphics::Shader;
using eve::graphics::Texture;
using eve::graphics::grass::BillboardMesh;
using eve::graphics::grass::Point;
using eve::graphics::grass::PackedAtlasInfo;
using eve::graphics::grass::SampleParams;
namespace {
#include "PathBesideSource.h"
EVE_DEFINE_PATH_BESIDE_SOURCE()
void makeTestPlane(std::vector<float> &pos, std::vector<float> &nrm, std::vector<uint32_t> &idx) {
eve::graphics::grass::makePlane(4.f, 4.f, 4, 4, pos, nrm, idx);
}
float minPairDist(const std::vector<Point> &pts) {
float best = 1e9f;
for (size_t i = 0; i < pts.size(); ++i) {
for (size_t j = i + 1; j < pts.size(); ++j) {
const glm::vec3 d = pts[i].position - pts[j].position;
best = std::min(best, glm::length(d));
}
}
return best;
}
} // namespace
TEST_CASE("graphics.Grass.bindDefaults") {
Shader sh;
sh.setKind(Shader::Kind::eMesh3D);
eve::graphics::grass::bindDefaults(&sh);
CHECK(sh.hasUniform("time"));
CHECK(sh.hasUniform("frameDuration"));
CHECK(sh.hasUniform("lightGreen"));
CHECK(sh.hasUniform("darkGreen"));
CHECK(sh.hasUniform("alwaysDark"));
CHECK_EQ(sh.getUniformIndex("time"), 0);
CHECK_EQ(sh.getUniformIndex("alwaysDark"), 5);
CHECK_EQ(sh.getUniformIndex("lightGreen"), 6);
CHECK_EQ(sh.getUniformIndex("darkGreen"), 9);
CHECK_EQ(sh.getUniformIndex("frameCount"), 12);
CHECK_EQ(sh.getUniformIndex("atlasCols"), 13);
CHECK_EQ(sh.getUniformIndex("atlasRows"), 14);
CHECK_EQ(sh.getUniformIndex("grassVariantCount"), 15);
CHECK_EQ(sh.getUniformIndex("leafVariantCount"), 16);
CHECK_EQ(sh.getUniformIndex("leafRowOffset"), 17);
CHECK_EQ(sh.getUniformIndex("windGlobals"), 18);
CHECK_EQ(sh.getUniformIndex("windPhaseDistance"), 22);
CHECK_EQ(sh.getUniformIndex("windFlex"), 24);
CHECK_EQ(sh.getUniformIndex("windFrequency"), 27);
CHECK_EQ(sh.getUniformIndex("windSineTime"), 30);
CHECK_EQ(sh.pushConstantData()[23], 0.f);
CHECK_EQ(sh.usedFloats(), 32);
}
TEST_CASE("graphics.Grass.paramNames") {
CHECK_EQ(eve::graphics::grass::paramCount(), 32);
CHECK_EQ(eve::graphics::grass::paramName(0), std::string("time"));
CHECK_EQ(eve::graphics::grass::paramName(5), std::string("alwaysDark"));
CHECK_EQ(eve::graphics::grass::paramName(12), std::string("frameCount"));
CHECK_EQ(eve::graphics::grass::paramName(13), std::string("atlasCols"));
CHECK_EQ(eve::graphics::grass::paramName(17), std::string("leafRowOffset"));
}
TEST_CASE("graphics.Grass.foliageProfileReusesStablePushLayout") {
Shader sh;
sh.setKind(Shader::Kind::eMesh3D);
eve::graphics::grass::GrassFoliageSettings settings;
settings.baseR = 0.2f; settings.baseG = 0.4f; settings.baseB = 0.6f;
settings.normalStrength = 1.7f; settings.renderDistance = 91.f; settings.fadeRange = 13.f;
settings.snowMinimumHeight = 8.f; settings.snowFadeDistance = 17.f; settings.snowProgress = 0.75f;
eve::graphics::grass::bindFoliage(&sh, settings);
CHECK_EQ(sh.usedFloats(), 32);
CHECK_EQ(sh.pushConstantData()[0], 8.f);
CHECK_EQ(sh.pushConstantData()[1], 1.7f);
CHECK_EQ(sh.pushConstantData()[6], 0.2f);
CHECK_EQ(sh.pushConstantData()[12], -1.f);
CHECK_EQ(sh.pushConstantData()[13], 91.f);
CHECK_EQ(sh.pushConstantData()[14], 13.f);
CHECK_EQ(sh.pushConstantData()[15], 17.f);
CHECK_EQ(sh.pushConstantData()[16], 0.75f);
CHECK_EQ(sh.pushConstantData()[17], 105.5f);
CHECK_EQ(sh.getUniformIndex("windGlobals"), 18);
}
TEST_CASE("graphics.Grass.terrainDetailOverwriteKeepsThreeDistanceSemantics") {
eve::graphics::grass::GrassFoliageSettings foliage;
eve::graphics::grass::TerrainDetailOverwriteSettings settings;
settings.pcgDetailDistance = -4;
settings.pcgFadeoutDistance = 12;
settings.unityDetailDistance = 140;
settings.unityDetailDensity = 0.35F;
settings.detailResolutionPerPatch = 4;
auto applied = eve::graphics::grass::applyTerrainDetailOverwrite(foliage, settings);
REQUIRE(applied.ok());
CHECK(applied.value() == static_cast<int>(eve::graphics::grass::TerrainDetailQuality::VeryHigh4));
CHECK(foliage.renderDistance == 0);
CHECK(foliage.fadeRange == 12);
CHECK(foliage.hardRenderDistance == 140);
CHECK(foliage.density == 0.35F);
auto before = foliage;
settings.unityDetailDensity = 2;
CHECK(!eve::graphics::grass::applyTerrainDetailOverwrite(foliage, settings).ok());
CHECK(foliage.renderDistance == before.renderDistance);
CHECK(eve::graphics::grass::terrainDetailQuality(7) ==
eve::graphics::grass::TerrainDetailQuality::VeryLow64);
}
TEST_CASE("graphics.Grass.photoModeAuthorityOwnsFourRuntimeMultipliers") {
auto* gfx=Graphics::create();auto* field=gfx->newGrassField();field->setPhotoModeAuthority(true);
REQUIRE(field->applyPhotoModeField({"m_globalGrassDensity",eve::PhotoModeDomain::Grass,2.f}).ok());
REQUIRE(field->applyPhotoModeField({"m_globalGrassDistance",eve::PhotoModeDomain::Grass,3.f}).ok());
REQUIRE(field->applyPhotoModeField({"m_cameraCellDistance",eve::PhotoModeDomain::Grass,1.5f}).ok());
REQUIRE(field->applyPhotoModeField({"m_cameraCellSubdivision",eve::PhotoModeDomain::Grass,int64_t{2}}).ok());
CHECK_EQ(field->getPhotoModeDensity(),2.f);CHECK_EQ(field->getPhotoModeDistance(),3.f);
CHECK_EQ(field->getPhotoModeCellDistance(),1.5f);CHECK_EQ(field->getPhotoModeCellSubdivision(),2);
CHECK_EQ(field->getTerrainDetailHardDistance(),472.5f);CHECK_EQ(field->getTerrainDetailDensity(),1.f);
CHECK(!field->applyPhotoModeField({"m_cameraCellSubdivision",eve::PhotoModeDomain::Grass,int64_t{9}}).ok());
CHECK_EQ(field->getPhotoModeCellSubdivision(),2);delete field;
}
TEST_CASE("graphics.Grass.spvMagic") {
CHECK(mesh3d_grass_vert_spv_count > 0);
CHECK(mesh3d_grass_frag_spv_count > 0);
CHECK_EQ(mesh3d_grass_vert_spv[0], 0x07230203u);
CHECK_EQ(mesh3d_grass_frag_spv[0], 0x07230203u);
}
TEST_CASE("graphics.Grass.poissonMinDistance") {
std::vector<float> pos, nrm;
std::vector<uint32_t> idx;
makeTestPlane(pos, nrm, idx);
SampleParams p;
p.radius = 0.45f;
p.maxPoints = 200;
p.seed = 7;
p.minSlopeDot = 0.1f;
const auto pts =
eve::graphics::grass::samplePoisson(pos.data(), nrm.data(), int(pos.size() / 3), idx.data(),
int(idx.size()), p);
CHECK(pts.size() >= 8);
CHECK(int(pts.size()) <= p.maxPoints);
const float minD = minPairDist(pts);
CHECK(minD + 1e-4f >= p.radius);
for (const auto &pt : pts) {
CHECK(std::abs(pt.position.y) < 1e-4f);
CHECK(pt.position.x >= -2.01f);
CHECK(pt.position.x <= 2.01f);
CHECK(pt.position.z >= -2.01f);
CHECK(pt.position.z <= 2.01f);
}
}
TEST_CASE("graphics.Grass.haltonCountAndCoverage") {
std::vector<float> pos, nrm;
std::vector<uint32_t> idx;
makeTestPlane(pos, nrm, idx);
const auto pts = eve::graphics::grass::sampleHalton(
pos.data(), nrm.data(), int(pos.size() / 3), idx.data(), int(idx.size()), 64, 3, 0.1f);
CHECK_EQ(int(pts.size()), 64);
CHECK_EQ(pts.front().id, 0u);
CHECK_EQ(pts.back().id, 63u);
float minX = 1e9f, maxX = -1e9f, minZ = 1e9f, maxZ = -1e9f;
for (const auto &pt : pts) {
minX = std::min(minX, pt.position.x);
maxX = std::max(maxX, pt.position.x);
minZ = std::min(minZ, pt.position.z);
maxZ = std::max(maxZ, pt.position.z);
}
CHECK(maxX - minX > 2.f);
CHECK(maxZ - minZ > 2.f);
}
TEST_CASE("graphics.Grass.sparseIsSparser") {
std::vector<float> pos, nrm;
std::vector<uint32_t> idx;
makeTestPlane(pos, nrm, idx);
SampleParams dense;
dense.radius = 0.3f;
dense.maxPoints = 400;
dense.seed = 1;
SampleParams sparse = dense;
sparse.radius = 1.2f;
sparse.maxPoints = 80;
sparse.seed = 99;
const auto d = eve::graphics::grass::samplePoisson(
pos.data(), nrm.data(), int(pos.size() / 3), idx.data(), int(idx.size()), dense);
const auto s = eve::graphics::grass::samplePoisson(
pos.data(), nrm.data(), int(pos.size() / 3), idx.data(), int(idx.size()), sparse);
CHECK(d.size() > s.size());
CHECK(s.size() >= 1);
}
TEST_CASE("graphics.Grass.billboardRootPivot") {
std::vector<Point> pts(1);
pts[0].position = glm::vec3(3.f, 1.5f, -2.f);
pts[0].id = 11;
pts[0].scale = 1.25f;
const BillboardMesh mesh = eve::graphics::grass::buildBillboards(pts, 0.4f, 0.8f);
CHECK_EQ(int(mesh.posXYZ.size()), 12);
CHECK_EQ(int(mesh.uvST.size()), 8);
CHECK_EQ(int(mesh.indices.size()), 6);
for (int i = 0; i < 4; ++i) {
CHECK(std::abs(mesh.posXYZ[size_t(i * 3 + 0)] - 3.f) < 1e-5f);
CHECK(std::abs(mesh.posXYZ[size_t(i * 3 + 1)] - 1.5f) < 1e-5f);
CHECK(std::abs(mesh.posXYZ[size_t(i * 3 + 2)] + 2.f) < 1e-5f);
CHECK(std::abs(mesh.nrmXYZ[size_t(i * 3 + 0)] - 11.f) < 1e-5f);
CHECK(std::abs(mesh.nrmXYZ[size_t(i * 3 + 1)] - 1.25f) < 1e-5f);
CHECK(std::abs(mesh.nrmXYZ[size_t(i * 3 + 2)]) < 1e-5f);
}
// Bottom edge UVs sit at v=0; their midpoint is the root (u=0.5).
CHECK(std::abs(mesh.uvST[1]) < 1e-5f);
CHECK(std::abs(mesh.uvST[3]) < 1e-5f);
CHECK(std::abs(0.5f * (mesh.uvST[0] + mesh.uvST[2]) - 0.5f) < 1e-5f);
}
TEST_CASE("graphics.Grass.swayDesyncByInstanceId") {
const int a = eve::graphics::grass::swayFrame(0.f, 0.12f, 0, 4);
const int b = eve::graphics::grass::swayFrame(0.f, 0.12f, 1, 4);
const int c = eve::graphics::grass::swayFrame(0.f, 0.12f, 2, 4);
CHECK(a >= 0);
CHECK(a < 4);
CHECK(b >= 0);
CHECK(b < 4);
CHECK(c >= 0);
CHECK(c < 4);
CHECK((a != b || b != c));
const int later = eve::graphics::grass::swayFrame(0.36f, 0.12f, 0, 4);
CHECK(later != a);
}
TEST_CASE("graphics.Grass.swayAtlasFourFrames") {
std::vector<uint8_t> rgba;
eve::graphics::grass::makeSwayAtlasRGBA(32, 32, 4, rgba);
CHECK_EQ(eve::graphics::grass::swayAtlasWidth(32, 4), 128);
CHECK_EQ(eve::graphics::grass::swayAtlasHeight(32), 32);
CHECK_EQ(int(rgba.size()), 128 * 32 * 4);
int opaque = 0;
int perFrame[4] = {};
for (int y = 0; y < 32; ++y) {
for (int x = 0; x < 128; ++x) {
const uint8_t a = rgba[(size_t(y) * 128u + size_t(x)) * 4u + 3u];
if (a > 16) {
++opaque;
perFrame[x / 32]++;
}
}
}
CHECK(opaque > 80);
for (int f = 0; f < 4; ++f) CHECK(perFrame[f] > 10);
// Filled tuft: the root band is denser than the tips (not five 1px sticks).
int baseOpaque = 0, tipOpaque = 0;
for (int x = 0; x < 32; ++x) {
for (int y = 24; y < 32; ++y) {
if (rgba[(size_t(y) * 128u + size_t(x)) * 4u + 3u] > 16) ++baseOpaque;
}
for (int y = 0; y < 8; ++y) {
if (rgba[(size_t(y) * 128u + size_t(x)) * 4u + 3u] > 16) ++tipOpaque;
}
}
CHECK(baseOpaque > tipOpaque);
CHECK(baseOpaque > 40);
// Frames actually differ (wind lean).
int diffs = 0;
for (int y = 0; y < 32; ++y) {
for (int x = 0; x < 32; ++x) {
const uint8_t a0 = rgba[(size_t(y) * 128u + size_t(x)) * 4u + 3u];
const uint8_t a3 = rgba[(size_t(y) * 128u + size_t(x + 96)) * 4u + 3u];
if (a0 != a3) ++diffs;
}
}
CHECK(diffs > 8);
}
TEST_CASE("graphics.Grass.fileSwayAtlasPacksFourGrassAndTwoLeaf") {
const std::vector<std::string> grass = {
pathBesideThisSource("Texture/grass_sway_0.png"),
pathBesideThisSource("Texture/grass_sway_1.png"),
pathBesideThisSource("Texture/grass_sway_2.png"),
pathBesideThisSource("Texture/grass_sway_3.png"),
};
const std::vector<std::string> leaf = {
pathBesideThisSource("Texture/leaf_sway_0.png"),
pathBesideThisSource("Texture/leaf_sway_1.png"),
};
for (const auto &p : grass) REQUIRE(std::filesystem::exists(p));
for (const auto &p : leaf) REQUIRE(std::filesystem::exists(p));
std::vector<uint8_t> rgba;
PackedAtlasInfo packed;
eve::graphics::grass::packSwayAtlasRGBA(grass, leaf, rgba, packed);
CHECK_EQ(packed.grassVariants, 4);
CHECK_EQ(packed.leafVariants, 2);
CHECK_EQ(packed.atlasCols, 8);
CHECK_EQ(packed.atlasRows, 4);
CHECK_EQ(packed.leafRowOffset, 2);
CHECK_EQ(packed.frames, 4);
CHECK(packed.width >= 512);
CHECK(packed.height >= 512);
CHECK_EQ(int(rgba.size()), packed.width * packed.height * 4);
auto opaqueInCell = [&](int col, int row) {
const int cellW = packed.width / packed.atlasCols;
const int cellH = packed.height / packed.atlasRows;
int n = 0;
for (int y = row * cellH; y < (row + 1) * cellH; ++y) {
for (int x = col * cellW; x < (col + 1) * cellW; ++x) {
if (rgba[(size_t(y) * size_t(packed.width) + size_t(x)) * 4u + 3u] > 16) ++n;
}
}
return n;
};
// Each grass variant is a 2x2 block on the top two frame-rows.
for (int v = 0; v < 4; ++v) {
CHECK(opaqueInCell(v * 2, 0) + opaqueInCell(v * 2 + 1, 0) + opaqueInCell(v * 2, 1) +
opaqueInCell(v * 2 + 1, 1) >
80);
}
// Leaf variants occupy the bottom two frame-rows.
CHECK(opaqueInCell(0, 2) + opaqueInCell(1, 2) + opaqueInCell(0, 3) + opaqueInCell(1, 3) > 80);
CHECK(opaqueInCell(2, 2) + opaqueInCell(3, 2) + opaqueInCell(2, 3) + opaqueInCell(3, 3) > 80);
}
TEST_CASE("graphics.Grass.layerFlag") {
Shader sh;
sh.setKind(Shader::Kind::eMesh3D);
eve::graphics::grass::bindDefaults(&sh);
eve::graphics::grass::bindLayer(&sh, true);
float dark = 0.f;
REQUIRE_EQ(sh.getFromVar("alwaysDark", &dark, sizeof(dark)), int(sizeof(dark)));
CHECK(dark > 0.5f);
eve::graphics::grass::bindLayer(&sh, false);
REQUIRE_EQ(sh.getFromVar("alwaysDark", &dark, sizeof(dark)), int(sizeof(dark)));
CHECK(dark < 0.5f);
}
TEST_CASE("graphics.Grass.billboardAlwaysDarkFlag") {
std::vector<Point> pts(1);
pts[0].position = glm::vec3(0.f, 0.f, 0.f);
pts[0].id = 3;
pts[0].scale = 1.f;
const BillboardMesh dark = eve::graphics::grass::buildBillboards(pts, 0.4f, 0.8f, true);
CHECK_EQ(int(dark.nrmXYZ.size()), 12);
for (int i = 0; i < 4; ++i) CHECK(dark.nrmXYZ[size_t(i * 3 + 2)] > 0.5f);
}
namespace {
void resetGrassScene() {
if (ecs::current()->getManager<Renderable3D>() != nullptr) {
auto view = ecs::View<Renderable3D, Renderable3D::MeshRenderer>();
for (auto it = view.begin(); it != view.end(); ++it) {
auto [mr] = *it;
mr->visible = false;
}
}
if (ecs::current()->getManager<Camera3D>() != nullptr) {
auto camView = ecs::View<Camera3D, Camera3D::Data>();
for (auto it = camView.begin(); it != camView.end(); ++it) {
auto [data] = *it;
data->active = false;
}
}
if (ecs::current()->getManager<Light3D>() != nullptr) {
auto lightView = ecs::View<Light3D, Light3D::Data>();
for (auto it = lightView.begin(); it != lightView.end(); ++it) {
auto [d] = *it;
d->enabled = false;
}
}
if (ecs::current()->getManager<Renderable2D>() != nullptr) {
auto view = ecs::View<Renderable2D, Renderable2D::Sprite>();
for (auto it = view.begin(); it != view.end(); ++it) {
auto [sp] = *it;
sp->visible = false;
}
}
}
Texture *makeSolidTex(Graphics *gfx, uint8_t r, uint8_t g, uint8_t b) {
const uint8_t px[4] = {r, g, b, 255};
return gfx->newTexture(1, 1, px);
}
Mesh *makeGroundMesh(Graphics *gfx, float sizeX, float sizeZ) {
std::vector<float> pos, nrm;
std::vector<uint32_t> idx;
eve::graphics::grass::makePlane(sizeX, sizeZ, 12, 12, pos, nrm, idx);
std::vector<float> uv(size_t(pos.size() / 3) * 2u, 0.f);
for (size_t i = 0; i < pos.size() / 3; ++i) {
uv[i * 2] = pos[i * 3] / sizeX + 0.5f;
uv[i * 2 + 1] = pos[i * 3 + 2] / sizeZ + 0.5f;
}
return gfx->newMeshFromArrays(pos.data(), nrm.data(), uv.data(), int(pos.size() / 3),
idx.data(), int(idx.size()));
}
void savePng(eve::image::ImageData *frame, const std::string &path) {
REQUIRE(frame != nullptr);
[[maybe_unused]] auto *const imageModule = eve::image::Image::create();
eve::filesystem::FileData *png =
frame->encode(medialoader::FormatHandler::ENCODED_PNG, "grass.png", false);
REQUIRE(png != nullptr);
REQUIRE(png->getSize() > 0);
std::error_code ec;
std::filesystem::create_directories(std::filesystem::path(path).parent_path(), ec);
{
std::ofstream out(path, std::ios::binary);
REQUIRE(out.good());
out.write(static_cast<const char *>(png->getData()),
static_cast<std::streamsize>(png->getSize()));
REQUIRE(out.good());
}
delete png;
std::printf("\ngrass render saved: %s\n", path.c_str());
}
} // namespace
TEST_CASE("graphics.Grass.gpuRenderScreenshot") {
auto *win = eve::window::Window::create();
auto *gfx = Graphics::create();
REQUIRE(win != nullptr);
REQUIRE(gfx != nullptr);
gfx->setMsaaSamples(0);
eve::window::WindowSettings s;
s.width = 960;
s.height = 540;
s.centered = true;
REQUIRE(win->setWindowSettings(s));
resetGrassScene();
auto *rc = gfx->getRenderControl();
REQUIRE(rc != nullptr);
rc->disable("aa");
rc->disable("msaa");
rc->disable("ao");
rc->disable("gi");
rc->enable("shadow");
rc->enable("forward");
rc->compile();
constexpr float kField = 10.f;
auto *ground = Renderable3D::create();
ground->setMesh(makeGroundMesh(gfx, kField, kField));
ground->setTexture(makeSolidTex(gfx, 68, 54, 30));
ground->setPosition(0.f, 0.f, 0.f);
ground->setMetallic(0.f);
ground->setRoughness(0.92f);
ground->setCastShadow(false);
ground->setReceiveShadow(true);
GrassField::BakeParams bake;
bake.denseRadius = 0.10f;
bake.sparseRadius = 0.46f;
bake.maxDense = 7000;
bake.maxSparse = 220;
bake.width = 0.58f;
bake.height = 0.98f;
bake.seed = 11;
bake.minSlopeDot = 0.2f;
bake.grassAtlasFiles = {
pathBesideThisSource("Texture/grass_sway_0.png"),
pathBesideThisSource("Texture/grass_sway_1.png"),
pathBesideThisSource("Texture/grass_sway_2.png"),
pathBesideThisSource("Texture/grass_sway_3.png"),
};
bake.leafAtlasFiles = {
pathBesideThisSource("Texture/leaf_sway_0.png"),
pathBesideThisSource("Texture/leaf_sway_1.png"),
};
GrassField *field = gfx->newGrassField();
REQUIRE(field != nullptr);
field->bakePlane(kField, kField, 16, 16, bake);
field->setTime(0.22f);
std::printf("\ngrass dense=%d sparse=%d\n", field->getDenseCount(), field->getSparseCount());
REQUIRE(field->getDenseCount() >= 800);
REQUIRE(field->getDenseMesh() != nullptr);
REQUIRE(field->getShader() != nullptr);
REQUIRE(field->getAtlas() != nullptr);
auto *dense = Renderable3D::create();
dense->setMesh(field->getDenseMesh());
dense->setTexture(field->getAtlas());
dense->setShader(field->getShader());
dense->setCastShadow(false);
dense->setReceiveShadow(true);
if (field->getSparseMesh()) {
auto *sparse = Renderable3D::create();
sparse->setMesh(field->getSparseMesh());
sparse->setTexture(field->getAtlas());
sparse->setShader(field->getShader());
sparse->setCastShadow(false);
sparse->setReceiveShadow(true);
}
auto *post = Renderable3D::create();
post->setMesh(gfx->newMeshCylinder(18, 1, true));
post->setTexture(makeSolidTex(gfx, 168, 148, 122));
post->setPosition(1.35f, 0.95f, -0.4f);
post->setScale(0.28f, 0.95f, 0.28f);
post->setMetallic(0.f);
post->setRoughness(0.7f);
post->setCastShadow(true);
post->setReceiveShadow(false);
auto *cam = Camera3D::createCamera();
cam->setEye(4.8f, 2.9f, 5.9f);
cam->setTarget(0.f, 0.45f, 0.f);
cam->setAmbient(0.18f, 0.20f, 0.16f);
cam->data()->nearZ = 0.1f;
cam->data()->farZ = 80.f;
auto *sun = Light3D::createLight("dir");
sun->setDirection(0.62f, 1.05f, 0.28f);
sun->setColor(1.f, 0.97f, 0.88f, 2.4f);
sun->setCastShadow(true);
sun->setShadowStrength(1.f);
sun->setShadowBias(0.003f);
auto *hud = Renderable2D::create();
hud->transform()->x = 0;
hud->transform()->y = 0;
hud->sprite()->width = 2;
hud->sprite()->height = 2;
hud->sprite()->r = 0.f;
hud->sprite()->g = 0.f;
hud->sprite()->b = 0.f;
hud->sprite()->a = 0.f;
hud->sprite()->visible = true;
gfx->setScreenReadbackEnabled(true);
gfx->setBackgroundColor(::Color(0.52f, 0.72f, 0.92f, 1.f));
for (int i = 0; i < 4; ++i) {
RenderSystem3D::render(*gfx);
RenderSystem::render(*gfx);
}
[[maybe_unused]] auto *const imageModule = eve::image::Image::create();
eve::image::ImageData *frame = gfx->newImageData();
REQUIRE(frame != nullptr);
std::unique_ptr<eve::image::ImageData> ownedFrame(frame);
int greenish = 0;
int darkGreen = 0;
int litGreen = 0;
const int w = gfx->getWidth();
const int h = gfx->getHeight();
for (int y = 0; y < h; y += 2) {
for (int x = 0; x < w; x += 2) {
const auto c = frame->getPixel(x, y);
if (c.g > c.r + 0.04f && c.g > c.b + 0.02f) {
++greenish;
const float luma = (c.r + c.g + c.b) / 3.f;
if (luma < 0.28f) ++darkGreen;
if (luma > 0.38f) ++litGreen;
}
}
}
// This fixed-size scene contains a broad field spanning multiple cascades.
// A WebGPU regression that left empty cascade layers uncleared turned the
// whole field dark while still satisfying the old handful-of-pixels check.
CHECK(greenish > 50000);
CHECK(darkGreen > 100);
CHECK(litGreen > 50000);
const std::string outDir = std::string(EVENGINE_TEST_BINARY_DIR) + "/out";
savePng(frame, outDir + "/grass_field.png");
savePng(frame, "/opt/cursor/artifacts/grass_field.png");
std::vector<uint8_t> atlasRgba;
PackedAtlasInfo atlasInfo;
eve::graphics::grass::packSwayAtlasRGBA(bake.grassAtlasFiles, bake.leafAtlasFiles, atlasRgba,
atlasInfo);
auto *atlasImg = new eve::image::ImageData(atlasInfo.width, atlasInfo.height, "RGBA8",
atlasRgba.data(), false);
savePng(atlasImg, outDir + "/grass_atlas.png");
savePng(atlasImg, "/opt/cursor/artifacts/grass_atlas.png");
delete atlasImg;
win->close();
}