#include "ManualPreview.h" #include "zeroerr/assert.h" #include "zeroerr/unittest.h" #include #include #include #include #include #include #include #include #include #include "common/Exception.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/Light.h" #include "graphics/Material.h" #include "graphics/Mesh.h" #include "graphics/Outline.h" #include "graphics/Quad.h" #include "graphics/RenderControl.h" #include "graphics/RenderSystem.h" #include "graphics/RenderSystem3D.h" #include "graphics/ScreenSpaceReflection.h" #include "graphics/Shader.h" #include "graphics/Texture.h" #include "graphics/Volumetric.h" #include "graphics/Water.h" #include "graphics/Waterfall.h" #include "image/Image.h" #include "image/ImageData.h" #include "stylize/Stylize.h" #include "stylize/StyleInstance.h" #include "stylize/StyleRecipe.h" #include "window/Window.h" // Color lives in eve::graphics (see graphics/Canvas.h); keep the unqualified form. using eve::graphics::Color; using eve::graphics::Canvas; using eve::graphics::Graphics; using eve::graphics::Mesh; using eve::graphics::Renderable2D; using eve::graphics::Renderable3D; using eve::graphics::Camera3D; using eve::graphics::RenderSystem; using eve::graphics::RenderSystem3D; using eve::graphics::Shader; using eve::graphics::Texture; using eve::image::ImageData; using eve::stylize::StyleChain; using eve::stylize::StyleInstance; using eve::stylize::StylePass; using eve::stylize::StyleRecipe; using eve::stylize::Stylize; using Colorf = ImageData::Colorf; namespace { bool near(float a, float b, float eps = 0.08f) { return std::fabs(a - b) <= eps; } float luma(const ::Color &c) { return (c.r + c.g + c.b) / 3.f; } ImageData *makeGradient(int w, int h) { auto *img = new ImageData(w, h, "RGBA8"); for (int y = 0; y < h; ++y) { for (int x = 0; x < w; ++x) { float u = float(x) / float(std::max(w - 1, 1)); float v = float(y) / float(std::max(h - 1, 1)); Colorf c{u, 0.35f + 0.4f * v, 1.f - u, 1.f}; // Hard edge for Sobel / outline styles. if (x > w / 2 && y > h / 3 && y < 2 * h / 3) c = Colorf{0.95f, 0.85f, 0.2f, 1.f}; img->setPixel(x, y, c); } } return img; } Texture *makeCylinderAlbedo(Graphics *gfx, int size = 256) { // Bold horizontal stripes only (no vertical seams — seams become false ink/toon outlines). // Avoid near-white bands so PBR/toon highlights keep readable form. std::vector px(size_t(size) * size_t(size) * 4); const int bandH = size / 5; const uint8_t palette[3][3] = { {210, 92, 58}, // terracotta {232, 186, 110}, // warm sand {48, 148, 158}, // teal }; for (int y = 0; y < size; ++y) { const int band = (y / std::max(bandH, 1)) % 3; for (int x = 0; x < size; ++x) { const size_t i = size_t(y * size + x) * 4; px[i + 0] = palette[band][0]; px[i + 1] = palette[band][1]; px[i + 2] = palette[band][2]; px[i + 3] = 255; } } return gfx->newTexture(size, size, px.data()); } void resetScene3D() { if (ecs::current()->getManager() != nullptr) { auto view = ecs::View(); for (auto it = view.begin(); it != view.end(); ++it) { auto [mr] = *it; mr->visible = false; } } if (ecs::current()->getManager() != nullptr) { auto camView = ecs::View(); for (auto it = camView.begin(); it != camView.end(); ++it) { auto [data] = *it; data->active = false; } } if (ecs::current()->getManager() != nullptr) { auto view = ecs::View(); for (auto it = view.begin(); it != view.end(); ++it) { auto [sp] = *it; sp->visible = false; } } } void saveImageDataPng(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, "stylize.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(png->getData()), static_cast(png->getSize())); REQUIRE(out.good()); } delete png; std::printf("stylize render saved: %s\n", path.c_str()); } std::string testOutDir() { return std::string(EVENGINE_TEST_BINARY_DIR) + "/out"; } /** Render cylinder with optional mesh shader; return screen ImageData (caller owns). */ ImageData *renderCylinderFrame(Graphics *gfx, Mesh *mesh, Texture *albedo, Shader *meshShader) { resetScene3D(); auto *ent = Renderable3D::create(); ent->meshRenderer()->mesh = mesh; ent->meshRenderer()->texture = albedo; ent->meshRenderer()->shader = meshShader; ent->meshRenderer()->visible = true; ent->setTint(1.f, 1.f, 1.f, 1.f); ent->transform()->yaw = 0.55f; ent->transform()->pitch = -0.15f; auto *cam = Camera3D::createCamera(); cam->setTarget(0.f, 0.f, 0.f); cam->setEye(2.4f, 1.5f, 3.2f); cam->data()->nearZ = 0.1f; cam->data()->farZ = 50.f; // Balanced fill so form reads without crushing shadows to black. cam->data()->ambientR = 0.28f; cam->data()->ambientG = 0.30f; cam->data()->ambientB = 0.34f; // Tiny 2D sprite keeps RenderSystem happy / present path consistent with model3d tests. 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); // Lifted slate so object silhouettes separate cleanly for outline/ink. gfx->setBackgroundColor(::Color(0.14f, 0.16f, 0.20f, 1.f)); // Empirically +Z / camera-side lights the visible face (see RenderSystem3D.lightingAffectsPixels). // Keep intensity near 1 so highlights don't blow cel/watercolor pigment to pure white. RenderSystem3D::setDirectionalLight(0.55f, 0.95f, 1.15f, 1.05f, 1.0f, 0.95f); for (int i = 0; i < 8; ++i) { ent->transform()->yaw = 0.45f + float(i) * 0.04f; RenderSystem3D::render(*gfx); RenderSystem::render(*gfx); } ::Color mid = gfx->getPixel(gfx->getWidth() / 2, gfx->getHeight() / 2); CHECK(luma(mid) > 0.12f); CHECK(luma(mid) < 0.92f); [[maybe_unused]] auto *const imageModule = eve::image::Image::create(); ImageData *frame = gfx->newImageData(); REQUIRE(frame != nullptr); return frame; } ImageData *applyPostToFrame(Graphics *gfx, ImageData *frame, StylePass *pass) { REQUIRE(frame != nullptr); REQUIRE(pass != nullptr); Texture *tex = gfx->newTexture(frame); REQUIRE(tex != nullptr); Canvas *rt = gfx->newCanvas(frame->getWidth(), frame->getHeight()); REQUIRE(rt != nullptr); gfx->setCanvas(rt); gfx->clear(::Color(0.14f, 0.16f, 0.20f, 1.f), std::nullopt, std::nullopt); if (pass->hasParam("time") && pass->getFloat("time") <= 0.f) pass->setTime(0.35f); pass->apply(gfx, tex); gfx->setCanvas(); ImageData *out = rt->newImageData(); REQUIRE(out != nullptr); return out; } } // namespace TEST_CASE("stylize.styles.registry") { auto *mod = Stylize::create(); REQUIRE(mod != nullptr); CHECK_EQ(mod->getName(), std::string("Stylize")); CHECK_EQ(mod->getStyleCount(), 16); CHECK(mod->hasStyle("cartoon")); CHECK(mod->hasStyle("watercolor")); CHECK(mod->hasStyle("ink")); CHECK(mod->hasStyle("pixel")); CHECK(mod->hasStyle("xray")); CHECK(mod->hasStyle("rim")); CHECK(mod->hasStyle("dissolve")); CHECK(mod->hasStyle("hologram")); CHECK(mod->hasStyle("snow")); CHECK(mod->hasStyle("vignette")); CHECK(mod->hasStyle("chromatic")); CHECK(mod->hasStyle("grain")); CHECK(!mod->hasStyle("oil")); CHECK(mod->hasMeshStyle("cartoon")); CHECK(mod->hasMeshStyle("ink")); CHECK(!mod->hasMeshStyle("watercolor")); CHECK(!mod->hasMeshStyle("pixel")); CHECK(mod->hasMeshStyle("xray")); CHECK(mod->hasMeshStyle("rim")); CHECK(mod->hasMeshStyle("dissolve")); CHECK(mod->hasMeshStyle("hologram")); CHECK(mod->hasMeshStyle("snow")); CHECK(!mod->hasMeshStyle("vignette")); CHECK(!mod->hasMeshStyle("chromatic")); CHECK(!mod->hasMeshStyle("grain")); CHECK(mod->supports("cartoon", "post")); CHECK(mod->supports("cartoon", "mesh")); CHECK(mod->supports("watercolor", "cpu")); CHECK(!mod->supports("watercolor", "mesh")); CHECK(!mod->supports("ink", "gbuffer")); // current shader is color/Sobel only CHECK(mod->supports("xray", "depth")); CHECK(!mod->supports("xray", "post")); CHECK(!mod->supports("xray", "cpu")); CHECK(!mod->supports("oil", "post")); CHECK(mod->supports("rim", "mesh")); CHECK(!mod->supports("rim", "post")); CHECK(mod->supports("vignette", "post")); CHECK(!mod->supports("vignette", "mesh")); CHECK(!mod->supports("vignette", "cpu")); CHECK_GT(mod->getStyleParamCount("pixel"), 0); CHECK_EQ(mod->getStyleParamName("pixel", 0), std::string("pixelSize")); CHECK(mod->getStyleParamName("pixel", 999).empty()); bool sawCartoon = false; for (int i = 0; i < mod->getStyleCount(); ++i) { if (mod->getStyleId(i) == "cartoon") sawCartoon = true; } CHECK(sawCartoon); } TEST_CASE("stylize.processImage.cpuAllStyles") { auto *mod = Stylize::create(); std::unique_ptr src(makeGradient(32, 24)); for (const char *style : {"cartoon", "watercolor", "ink", "pixel"}) { std::unique_ptr out(mod->processImage(src.get(), style)); REQUIRE(out.get() != nullptr); CHECK_EQ(out->getWidth(), 32); CHECK_EQ(out->getHeight(), 24); CHECK_EQ(out->getFormat(), std::string("RGBA8")); Colorf p = out->getPixel(8, 8); CHECK(p.a > 0.9f); // Must differ from a flat identity copy somewhere in the image. bool changed = false; for (int y = 0; y < 24 && !changed; ++y) { for (int x = 0; x < 32 && !changed; ++x) { Colorf a = src->getPixel(x, y); Colorf b = out->getPixel(x, y); if (!near(a.r, b.r, 0.02f) || !near(a.g, b.g, 0.02f) || !near(a.b, b.b, 0.02f)) changed = true; } } CHECK(changed); } bool threw = false; try { mod->processImage(src.get(), "unknown"); } catch (const eve::Exception &) { threw = true; } CHECK(threw); } TEST_CASE("stylize.instance.parameterSchema") { auto *mod = Stylize::create(); std::unique_ptr pixel(mod->newInstance("pixel")); REQUIRE(pixel.get() != nullptr); CHECK_EQ(pixel->getStyle(), std::string("pixel")); CHECK_EQ(pixel->getStage(), std::string("afterTonemap")); CHECK(pixel->requiresInput("color")); CHECK(!pixel->requiresInput("depth")); CHECK(pixel->hasParam("pixelSize")); CHECK(!pixel->hasParam("texelW")); CHECK_EQ(pixel->getFloat("pixelSize"), 5.f); pixel->setFloat("pixelSize", 999.f); CHECK_EQ(pixel->getFloat("pixelSize"), pixel->getParamMax("pixelSize")); CHECK(pixel->isOverridden("pixelSize")); pixel->reset("pixelSize"); CHECK(!pixel->isOverridden("pixelSize")); CHECK_EQ(pixel->getFloat("pixelSize"), pixel->getParamDefault("pixelSize")); } TEST_CASE("stylize.processImage.inkIsDesaturated") { auto *mod = Stylize::create(); std::unique_ptr src(makeGradient(48, 32)); std::unique_ptr ink(mod->processImage(src.get(), "ink")); REQUIRE(ink.get() != nullptr); // Ink wash should collapse toward gray/sepia paper — chroma reduced. int nearGray = 0; for (int y = 0; y < 32; ++y) { for (int x = 0; x < 48; ++x) { Colorf c = ink->getPixel(x, y); float mx = std::max(c.r, std::max(c.g, c.b)); float mn = std::min(c.r, std::min(c.g, c.b)); if (mx - mn < 0.25f) ++nearGray; } } CHECK_GT(nearGray, 48 * 32 / 2); } TEST_CASE("stylize.processImage.pixelQuantizes") { auto *mod = Stylize::create(); std::unique_ptr src(makeGradient(64, 32)); std::unique_ptr pix(mod->processImage(src.get(), "pixel")); REQUIRE(pix.get() != nullptr); // Neighboring pixels inside a 4x4 block should match (UV snap). Colorf a = pix->getPixel(0, 0); Colorf b = pix->getPixel(1, 1); CHECK(near(a.r, b.r, 0.001f)); CHECK(near(a.g, b.g, 0.001f)); CHECK(near(a.b, b.b, 0.001f)); } TEST_CASE("stylize.api.passFromShaderAndChain") { auto *win = eve::window::Window::create(); auto *gfx = Graphics::create(); REQUIRE(win != nullptr); REQUIRE(gfx != nullptr); eve::window::WindowSettings s; s.width = 128; s.height = 96; s.centered = true; REQUIRE(win->setWindowSettings(s)); auto *mod = Stylize::create(); Shader *raw = mod->newPostShader(gfx, "pixel"); REQUIRE(raw != nullptr); StylePass *custom = mod->newPassFromShader("custom_pixel", raw); REQUIRE(custom != nullptr); CHECK_EQ(custom->getStyle(), std::string("custom_pixel")); StylePass *a = mod->newPass(gfx, "cartoon"); StylePass *b = mod->newPass(gfx, "pixel"); REQUIRE(a != nullptr); REQUIRE(b != nullptr); StyleChain *chain = mod->newChain(); REQUIRE(chain != nullptr); chain->add(a); chain->add(b); CHECK_EQ(chain->getPassCount(), 2); CHECK(chain->getPass(0) == a); std::unique_ptr img(makeGradient(64, 48)); Texture *tex = gfx->newTexture(img.get()); Canvas *dest = gfx->newCanvas(64, 48); Canvas *temp = gfx->newCanvas(64, 48); REQUIRE(tex != nullptr); REQUIRE(dest != nullptr); REQUIRE(temp != nullptr); custom->applyTo(gfx, tex, dest); ::Color p0 = dest->getPixel(32, 24); CHECK_GT(p0.a, 0.5f); chain->apply(gfx, tex, dest, temp); ::Color p1 = dest->getPixel(32, 24); CHECK_GT(p1.a, 0.5f); std::unique_ptr watercolor(mod->newInstance("watercolor")); std::unique_ptr pixel(mod->newInstance("pixel")); std::unique_ptr recipe(mod->newRecipe()); recipe->add(pixel.get()); recipe->add(watercolor.get()); recipe->compile(gfx); CHECK(recipe->isCompiled()); CHECK_EQ(recipe->getStage(), std::string("afterTonemap")); CHECK_EQ(recipe->getStyle(0)->getStyle(), std::string("watercolor")); recipe->apply(gfx, tex, dest); ::Color p2 = dest->getPixel(32, 24); CHECK_GT(p2.a, 0.5f); delete chain; delete custom; delete a; delete b; win->close(); } TEST_CASE("stylize.gpu.postPassAndMeshShader") { auto *win = eve::window::Window::create(); auto *gfx = Graphics::create(); REQUIRE(win != nullptr); REQUIRE(gfx != nullptr); eve::window::WindowSettings s; s.width = 256; s.height = 192; s.centered = true; REQUIRE(win->setWindowSettings(s)); auto *mod = Stylize::create(); for (const char *style : {"cartoon", "watercolor", "ink", "pixel"}) { StylePass *pass = mod->newPass(gfx, style); REQUIRE(pass != nullptr); CHECK_EQ(pass->getStyle(), std::string(style)); REQUIRE(pass->getShader() != nullptr); REQUIRE(pass->getShader()->gpuHandle != nullptr); CHECK(pass->hasParam("texelW")); if (pass->hasParam("time")) pass->setTime(0.25f); std::unique_ptr img(makeGradient(64, 48)); auto *tex = gfx->newTexture(img.get()); REQUIRE(tex != nullptr); Canvas *rt = gfx->newCanvas(128, 96); REQUIRE(rt != nullptr); gfx->setCanvas(rt); gfx->clear(::Color(0.1f, 0.1f, 0.1f, 1.f), std::nullopt, std::nullopt); pass->apply(gfx, tex); gfx->setCanvas(); ::Color p = rt->getPixel(64, 48); CHECK_GT(p.a, 0.5f); delete pass; } Shader *toonMesh = mod->newMeshShader(gfx, "cartoon"); REQUIRE(toonMesh != nullptr); REQUIRE(toonMesh->gpuHandle != nullptr); CHECK(toonMesh->hasUniform("bands")); Shader *inkMesh = mod->newMeshShader(gfx, "ink"); REQUIRE(inkMesh != nullptr); REQUIRE(inkMesh->gpuHandle != nullptr); CHECK(inkMesh->hasUniform("washLevels")); bool threw = false; try { mod->newMeshShader(gfx, "watercolor"); } catch (const eve::Exception &) { threw = true; } CHECK(threw); } TEST_CASE("stylize.render.cylinderStyleGallery") { auto *win = eve::window::Window::create(); auto *gfx = Graphics::create(); REQUIRE(win != nullptr); REQUIRE(gfx != nullptr); eve::window::WindowSettings s; s.width = 512; s.height = 384; s.centered = true; REQUIRE(win->setWindowSettings(s)); Mesh *cylinder = gfx->newMeshCylinder(64, 1, true); REQUIRE(cylinder != nullptr); CHECK_GT(cylinder->indexCount, 0); Texture *albedo = makeCylinderAlbedo(gfx, 128); REQUIRE(albedo != nullptr); auto *mod = Stylize::create(); const std::string outDir = testOutDir(); std::unique_ptr baseFrame(renderCylinderFrame(gfx, cylinder, albedo, nullptr)); REQUIRE(baseFrame.get() != nullptr); saveImageDataPng(baseFrame.get(), outDir + "/cylinder_base.png"); // Cartoon uses object-space cel mesh first, then outline post (UTS-like). Shader *toonMesh = mod->newMeshShader(gfx, "cartoon"); REQUIRE(toonMesh != nullptr); toonMesh->sendFloat("bands", 3.f); toonMesh->sendFloat("rimPower", 2.6f); toonMesh->sendFloat("rimStrength", 0.45f); toonMesh->sendFloat("posterize", 5.f); std::unique_ptr cartoonBase(renderCylinderFrame(gfx, cylinder, albedo, toonMesh)); const char *styles[] = {"cartoon", "ink", "watercolor", "pixel"}; for (const char *id : styles) { StylePass *pass = mod->newPass(gfx, id); REQUIRE(pass != nullptr); ImageData *srcFrame = baseFrame.get(); if (std::string(id) == "cartoon") { srcFrame = cartoonBase.get(); // Mesh already did cel; post lightly reinforces bands + silhouette ink. pass->setFloat("bands", 3.f); pass->setFloat("outlineStrength", 1.35f); pass->setFloat("outlineThreshold", 0.08f); pass->setFloat("posterize", 5.f); pass->setFloat("outlineWidth", 2.0f); pass->setFloat("shadowLift", 0.18f); pass->setFloat("softEdge", 0.12f); } else if (std::string(id) == "watercolor") { pass->setFloat("blurAmount", 2.2f); pass->setFloat("edgeDarken", 1.2f); pass->setFloat("paperStrength", 0.6f); pass->setFloat("distortion", 0.55f); pass->setFloat("bleed", 0.58f); pass->setFloat("saturation", 0.95f); pass->setFloat("granulation", 0.45f); } else if (std::string(id) == "ink") { pass->setFloat("inkContrast", 1.35f); pass->setFloat("washLevels", 5.f); pass->setFloat("edgeThreshold", 0.18f); pass->setFloat("diffusion", 3.5f); pass->setFloat("inkDensity", 0.75f); pass->setFloat("edgeStrength", 1.1f); pass->setFloat("paperR", 0.96f); pass->setFloat("paperG", 0.93f); pass->setFloat("paperB", 0.86f); } else if (std::string(id) == "pixel") { pass->setFloat("pixelSize", 6.f); pass->setFloat("paletteSteps", 5.f); pass->setFloat("ditherStrength", 0.12f); pass->setFloat("toonBands", 3.f); pass->setFloat("sharpness", 1.f); pass->setFloat("outline", 0.95f); } std::unique_ptr styled(applyPostToFrame(gfx, srcFrame, pass)); delete pass; REQUIRE(styled.get() != nullptr); const std::string name = std::string("cylinder_") + id + ".png"; saveImageDataPng(styled.get(), outDir + "/" + name); // Sanity: some non-background pixels exist (cylinder albedo is warm). int warm = 0; int changed = 0; const int w = styled->getWidth(); const int h = styled->getHeight(); for (int y = h / 4; y < 3 * h / 4; y += 4) { for (int x = w / 4; x < 3 * w / 4; x += 4) { Colorf c = styled->getPixel(x, y); if (c.r + c.g + c.b > 0.2f) ++warm; const auto original = srcFrame->getPixel(x, y); if (std::fabs(c.r - original.r) + std::fabs(c.g - original.g) + std::fabs(c.b - original.b) > 0.04f) ++changed; } } REQUIRE(warm > 20); REQUIRE(changed > 20); if (!eve::test::manualPreviewEnabled()) continue; // Live gallery: hold each style on the window so the look is visible. Texture *preview = gfx->newTexture(styled.get()); REQUIRE(preview != nullptr); gfx->setBackgroundColor(::Color(0.08f, 0.08f, 0.1f, 1.f)); for (int frame = 0; frame < 40; ++frame) { gfx->clearScreen(); gfx->drawTexturedRect(preview, 0.f, 0.f, float(gfx->getWidth()), float(gfx->getHeight()), ::Color(1.f, 1.f, 1.f, 1.f)); gfx->present(); SDL_Event e; while (SDL_PollEvent(&e)) { if (e.type == SDL_QUIT) break; } SDL_Delay(16); } } win->close(); }