diff --git a/CMakeLists.txt b/CMakeLists.txt index 571ae59a05..0273bf9325 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -3363,6 +3363,8 @@ if(CLIENT) ) set_src(GAME_MAP GLOB_RECURSE src/game/map # tidy-alphabetical-start + entity_outline.h + entity_regions.h envelope_extrema.cpp envelope_extrema.h envelope_manager.h @@ -3375,6 +3377,7 @@ if(CLIENT) render_layer.h render_map.cpp render_map.h + rounded_tiles.h # tidy-alphabetical-end ) set(GAME_GENERATED_CLIENT @@ -4019,6 +4022,7 @@ if((GTEST_FOUND OR DOWNLOAD_GTEST) AND SERVER) packer_test.cpp prng_test.cpp replace_words_test.cpp + rounded_tiles_test.cpp score_test.cpp secure_random_test.cpp server_test.cpp diff --git a/data/shader/tile.vert b/data/shader/tile.vert index 6288371e99..8477f4c93c 100644 --- a/data/shader/tile.vert +++ b/data/shader/tile.vert @@ -1,6 +1,6 @@ layout (location = 0) in vec2 inVertex; #ifdef TW_TILE_TEXTURED -layout (location = 1) in uvec4 inVertexTexCoord; +layout (location = 1) in vec4 inVertexTexCoord; #endif uniform mat4x2 gPos; diff --git a/data/shader/tile_border.vert b/data/shader/tile_border.vert index f8f71ebda5..4b29b2b626 100644 --- a/data/shader/tile_border.vert +++ b/data/shader/tile_border.vert @@ -1,6 +1,6 @@ layout (location = 0) in vec2 inVertex; #ifdef TW_TILE_TEXTURED -layout (location = 1) in uvec4 inVertexTexCoord; +layout (location = 1) in vec4 inVertexTexCoord; #endif uniform mat4x2 gPos; diff --git a/data/shader/vulkan/tile.vert b/data/shader/vulkan/tile.vert index 7dd9d35fdc..a5274d733b 100644 --- a/data/shader/vulkan/tile.vert +++ b/data/shader/vulkan/tile.vert @@ -3,7 +3,7 @@ layout (location = 0) in vec2 inVertex; #ifdef TW_TILE_TEXTURED -layout (location = 1) in uvec4 inVertexTexCoord; +layout (location = 1) in vec4 inVertexTexCoord; #endif layout(push_constant) uniform SPosBO { diff --git a/data/shader/vulkan/tile_border.vert b/data/shader/vulkan/tile_border.vert index 5463fdf39e..c7aa197c5c 100644 --- a/data/shader/vulkan/tile_border.vert +++ b/data/shader/vulkan/tile_border.vert @@ -3,7 +3,7 @@ layout (location = 0) in vec2 inVertex; #ifdef TW_TILE_TEXTURED -layout (location = 1) in uvec4 inVertexTexCoord; +layout (location = 1) in vec4 inVertexTexCoord; #endif layout(push_constant) uniform SPosBO { diff --git a/docs/entity_rounding.md b/docs/entity_rounding.md new file mode 100644 index 0000000000..f69cace052 --- /dev/null +++ b/docs/entity_rounding.md @@ -0,0 +1,79 @@ +# Entity tile rounding + +The client can round the contour of solid/nohook, freeze/unfreeze, death and numbered teleporter regions. This changes rendering only; tile IDs, maps, collision and prediction are unchanged. + +## Settings + +The controls are in TClient settings, under **Tile Outlines**. They are also available in the console and saved in the client configuration: + +| Setting | Default | Values | +| --- | --- | --- | +| `bc_entities_rounding` | `0` | `0`–`100` percent; `0` disables rounding | +| `bc_entities_rounding_mode` | `2` | `0`: outer corners, `1`: inner corners, `2`: both | + +For example: + +```text +bc_entities_rounding 75 +bc_entities_rounding_mode 2 +``` + +The maximum radius is half a tile (16 world units). At 100%, an isolated tile is a circle when outer corners are enabled; inner-only mode keeps it square. Connected regions retain continuous straight edges. Diagonal contacts remain separate. Existing outline enable, color, width and entities-only settings still apply. Switch tile artwork follows the visible entity contour when it overlaps a rounded region. Decoration, speedup arrows and tune tiles retain their original geometry. Number overlays remain text and are scaled into rounded outer corners. + +## Rendering + +`CEntityRegions` supplies the same validated category map to entity layers and outlines, retaining the existing layer and category priorities. A separate region key includes the visible tele/switch asset type and the category of the buried layer. At a three-against-one junction between different visible categories, the lone tile cuts a circular corner and the diagonal neighbor draws the complementary wedge using its own asset. A covered lower-layer tile stays square beneath the curved visible tile, so its artwork fills exposed space. Both sides share one arc, and only the higher-priority category draws its outline. Other mixed-category seams remain straight; different tele/switch assets and buried-underlay changes do not create a spurious curve. `RoundedTiles` builds a subdivision mesh only around affected corners. Outer corners warp the whole picture into the curve; open-sky inner corners keep artwork at its original map coordinates and extend edge pixels into the added area. Tile rotation and reflection remain supported. + +Buffered tile vertices use floating-point texture coordinates. Each map cell records its own quad count. The OpenGL and Vulkan tile/border layouts agree with the uploaded vertex format. The unbuffered path uses the same shape generator, with freeform texture-array draws or atlas coordinates as supported by the backend. + +Shapes are cached by neighborhood. Buffered tiles use a fixed four-step corner mesh, while high zoom draws only visible tiles at the screen-space detail needed there. Zoom-only detail changes no longer re-upload every map tile. Radius and mode changes still rebuild the buffered geometry. Atlas selection is resolved by the existing texture path and does not require rebuilding UVs. Culling includes the neighboring cell so expanded junctions do not disappear at screen edges. + +Outlines use the distance band inside the same contour, clipped to a disjoint tessellation to avoid double alpha at joins. Cached quad containers are reused by neighborhood and width. Straight walls use a smaller rectangular mesh. A boundary index built on map load visits only visible contour cells, rather than rescanning the full viewport every frame. Repeated contours outside map borders use scaled straight strips. Map changes, shutdown and rounding changes release the containers. + +Front-layer through/through-cut artwork and the entity tile beneath it share the same rounded silhouette. A complementary inner wedge samples the through asset in the diagonal cell's local coordinates. This continues its repeating stripe pattern into the added area instead of stretching a clamped edge texel. Straight outline segments ending at an adjacent arc use flat caps. The signed cap distance remains continuous while clipping mesh cells, preserving the last part of the line without an endpoint spur. The two side tiles also retain that arc as an outline distance source, while their geometry stays square. This fills the inward part of the curved band on both sides of the tangent without overlapping tile meshes; material priority still determines who draws a shared arc. Number overlays use one size for all tiles at the selected radius, so an interior label does not grow relative to labels on the rounded ends. + +## Verification + +`src/test/rounded_tiles_test.cpp` is registered with the existing GTest runner. It covers disabled geometry, the maximum-radius circle, full UV coverage, concave junction area, all neighborhood patterns and modes, shared-edge continuity, outline area and the detail error bound. + +Run the project's `testrunner` with `--gtest_filter=RoundedTiles.*:EntityRegions.*:EntityOutlineIndex.*`. Build `game-client` with `VULKAN=ON` to validate both compiled backends and Vulkan shaders. + +Verified on 2026-09-22: + +- Release `game-client` and the local test server build successfully. The client build reports zero warnings and errors. +- The original eight `RoundedTiles` tests pass when compiled with MSVC and the project's pinned GTest sources. GTest is not installed globally on this machine, so this run used a standalone test executable. +- OpenGL 3.3, OpenGL 1.5 (legacy unbuffered rendering), and Vulkan successfully connect to a local `Tutorial` server with 100% rounding, both corner modes and all outline categories enabled. Each run exits normally after 20 seconds with no graphics errors in the client log. Test hardware: NVIDIA GeForce GTX 1650 SUPER. +- OpenGL 2.1 also passes a 15-second run at 50% with inner corners only; OpenGL 3.3 passes with rounding disabled. Both connect to the same map and exit normally without graphics errors. +- A software rasterization of the actual shape and outline generator was inspected for 0/50/100% and modes 0/1/2. This checks the mesh silhouette, not GPU screenshots. + +The local verification files are in the ignored `build-rounding` directory: `rounded-tests.exe`, `rounding-preview.png`, build logs and `runtime` profiles/logs. The test profile uses its own `storage.cfg`; it does not load or overwrite the user's settings. Launching the Visual Studio output directly requires DLLs from the build root and data/shader paths in storage. After removing generated shader files, reconfigure with `-U VULKAN_SHADER_FILE_SHA256` to regenerate the Vulkan shader cache. + +Further visual checks remain for the user's exact map and atlas, map borders, FPS fog and live setting changes. Window automation was unavailable in this environment, so game screenshots were captured directly through a temporary graphics hook. These short runs are not a controlled performance or GPU-memory benchmark. Compare these metrics on the same map and camera before and after enabling rounding. + +On 2026-09-23, nine game screenshots per backend were captured with a temporary test hook on a local fixture map. The captures cover 0%, 50%, 100%; outer, inner and combined modes; outline widths 2 and 16; and outlines off. OpenGL 3.3, Vulkan and unbuffered OpenGL 1.5 all rendered the expected silhouettes. The entity regions in corresponding Vulkan and OpenGL 3.3 images were pixel-identical in six sampled scene rectangles for all nine cases. View the local image gallery at `build-rounding/rounding-visual-check.html`. The capture hook is removed from the delivered source; the generated fixture and images stay in the ignored build directory. + +A second fixture placed different entity categories beside and on top of each other at high zoom. It exposed a mismatch between the visible tile and outline plus a triangular outline spike at a category junction. The geometry now uses the same exact-category neighborhood for both, blocks curves at occupied neighboring categories and applies outline priority only to the shared edge. The overlap capture shows switch and numbered teleporter artwork following the rounded silhouette at 100%, a square silhouette at 0% and the same tile silhouette with outlines disabled. + +Further captures with numbered teleporter L-junctions reproduced oversized numbers and a sharp diagonal from the asset's internal frame. Numbers now fit within rounded outer corners. The inner patch keeps artwork in the original cell and extends its edge texels into the curved addition; the large diagonal artifact is gone. A synthetic 256-neighborhood outline benchmark dropped from about 7.5 to 1.8 seconds at 16 steps and from 57 to 15.5 seconds at 32 steps after skipping interior mesh patches. This is a construction benchmark, not a measured in-game frame time. + +Verified on 2026-09-24 with the user's Felian map at cell (421,132): screenshots in `build-rounding/runtime/screenshots/felian-center-421-132-asset-*` cover 0%, 50%, 100%, all three modes, and outline widths 0, 2, and 16. The former olive corner fragments at a tele tile over unfreeze are gone. At the neighboring tele-type seam, red pixels more than two screen pixels outside the tile boundary dropped to zero. All 18 focused geometry/region tests pass. These captures used the bundled default entity artwork; a custom entity pack may need separate visual verification. + +The later user screenshot exposed a separate discontinuity at stair junctions: with an inner arc disabled, straight outline bands in neighboring tiles touch only diagonally. The outline now inserts a join source in the third tile at that specific three-against-one topology, provided the diagonal region does not own the outline. A 4-connected raster test at four pixels per world unit reproduces two components without the join and one with it. All 20 focused tests pass. The client's Release build succeeds; the exact custom-artwork location has not been re-captured. + +The covered material junction now uses complementary geometry instead of suppressing both corners. A raster partition test checks that two materials cover every sampled point exactly once at three radii and all three modes. The two straight outlines next to the curve stop at its tangents, and only one material owns the arc. All 24 focused tests pass. A software preview in `build-rounding/covered-preview.png` shows the same staircase at 0%, 50%, and 100%. + +The later jagged-edge regression had two causes at an open-sky three-against-one junction. The diagonal tile bent its whole corner mesh across both side tiles, and the outline fast path widened a shortened straight segment into a full-width band. Now the diagonal tile retains its original square and adds only the circular wedge in the empty cell; the two side tiles stop their contours at the arc tangents. The rectangular outline fast path is restricted to complete tile sides. A sampled partition test and a tangent-extent test cover these failures. All 27 focused tests pass. Close-up screenshots from the game client at 100% in all three modes and at 50% are in `build-rounding/runtime/screenshots/qa-close-*`; Felian checks are in `qa-felian-*` in the same directory. The temporary inactive-window screenshot allowance used for these captures is removed from the source. + +Verified on 2026-09-28: 32 focused tests pass, including repeating through-hook wedge UVs, a fractional-radius straight cap, and the arc's inward band in both side tiles. The side-tile regression fails in all 48 sampled radius/mode/detail combinations before the fix. Fresh game captures with the user's `my_entities1` atlas cover 100% in modes 0/1/2 and 77% in mode 2 on OpenGL 1.5, plus Vulkan at 100%. This exercises the unbuffered and buffered texture paths. Three tangent rectangles in the 100% combined screenshot change from two or three disconnected white-outline components to one 4-connected component each. These captures use a local fixture, not the live Felian server. View `build-rounding/through-rounding-check.html` for the unedited screenshots. The temporary auto-capture hook is removed from the final source and executable. + +### Integration with current BestClient main + +Merged upstream main `f7295c203cb80a8b1e760c5c7332269be1ed8aae` on 2026-09-28. The renderer uses the current screen rectangle, layer initialization callback, vertex color and FPS fog APIs. The updated bundled libraries and all 38 Vulkan shaders build with the client and server in Release mode. + +The full test runner passes 407 tests, with three existing tests disabled. The 36 entity rounding tests include four boundary-index regressions: empty views, exact neighborhood masks, bounded traversal at extreme zoom, and equivalent coverage outside map borders. + +Fresh game captures verify outer-only, inner-only and combined rounding at 100%, combined rounding at 77%, and runtime changes through 0%, 50% and 77%. OpenGL 1.5, OpenGL 3.3 and Vulkan render the through-hook fixture successfully. The three sampled tangent rectangles have one connected white-outline component each at 100% and 77%. Teleporter labels keep the same size at the ends and in the middle of the L-shaped region. Felian was also checked at close and extreme far zoom. Temporary screenshot and performance instrumentation is removed from the production source. + +On Felian with Vulkan, a 1600-by-1000 viewport covering 1546-by-966 tiles, 100% combined rounding and all outlines enabled, steady samples improved from 59–86 FPS to 250–263 FPS. Outline CPU time fell from 9–14 ms to 1.1–1.2 ms, and visited cells fell from 1,493,436 to 8,953. These are local measurements on a GTX 1650 SUPER, not a guarantee for other hardware. Rounding disabled reached 490–569 FPS, but its existing central outline culling cap renders a smaller area, so that result is not an equivalent-coverage comparison. + +Unedited captures: [through-hook junctions at 100%](images/entity-rounding-through.png), [fractional radius at 77%](images/entity-rounding-fractional.png), [consistent teleporter labels](images/entity-rounding-labels.png), and [Felian at extreme far zoom](images/entity-rounding-far.png). diff --git a/docs/images/entity-rounding-far.png b/docs/images/entity-rounding-far.png new file mode 100644 index 0000000000..5b19f1780a Binary files /dev/null and b/docs/images/entity-rounding-far.png differ diff --git a/docs/images/entity-rounding-fractional.png b/docs/images/entity-rounding-fractional.png new file mode 100644 index 0000000000..2282639428 Binary files /dev/null and b/docs/images/entity-rounding-fractional.png differ diff --git a/docs/images/entity-rounding-labels.png b/docs/images/entity-rounding-labels.png new file mode 100644 index 0000000000..c7453da5a9 Binary files /dev/null and b/docs/images/entity-rounding-labels.png differ diff --git a/docs/images/entity-rounding-through.png b/docs/images/entity-rounding-through.png new file mode 100644 index 0000000000..44c2411878 Binary files /dev/null and b/docs/images/entity-rounding-through.png differ diff --git a/src/engine/client/backend/opengl/backend_opengl.cpp b/src/engine/client/backend/opengl/backend_opengl.cpp index 04106685e1..9f2844a940 100644 --- a/src/engine/client/backend/opengl/backend_opengl.cpp +++ b/src/engine/client/backend/opengl/backend_opengl.cpp @@ -2096,7 +2096,7 @@ void CCommandProcessorFragment_OpenGL2::Cmd_RenderBorderTile(const CCommandBuffe if(IsTextured) { glEnableVertexAttribArray(1); - glVertexAttribIPointer(1, 4, GL_UNSIGNED_BYTE, BufferContainer.m_ContainerInfo.m_Stride, BufferContainer.m_ContainerInfo.m_vAttributes[1].m_pOffset); + glVertexAttribPointer(1, 4, GL_FLOAT, false, BufferContainer.m_ContainerInfo.m_Stride, BufferContainer.m_ContainerInfo.m_vAttributes[1].m_pOffset); } size_t RealDrawCount = pCommand->m_DrawNum * 4; @@ -2150,7 +2150,7 @@ void CCommandProcessorFragment_OpenGL2::Cmd_RenderTileLayer(const CCommandBuffer if(IsTextured) { glEnableVertexAttribArray(1); - glVertexAttribIPointer(1, 4, GL_UNSIGNED_BYTE, BufferContainer.m_ContainerInfo.m_Stride, BufferContainer.m_ContainerInfo.m_vAttributes[1].m_pOffset); + glVertexAttribPointer(1, 4, GL_FLOAT, false, BufferContainer.m_ContainerInfo.m_Stride, BufferContainer.m_ContainerInfo.m_vAttributes[1].m_pOffset); } for(int i = 0; i < pCommand->m_IndicesDrawNum; ++i) diff --git a/src/engine/client/backend/vulkan/backend_vulkan.cpp b/src/engine/client/backend/vulkan/backend_vulkan.cpp index 74a49b3103..33365a7d30 100644 --- a/src/engine/client/backend/vulkan/backend_vulkan.cpp +++ b/src/engine/client/backend/vulkan/backend_vulkan.cpp @@ -5076,7 +5076,7 @@ class CCommandProcessorFragment_Vulkan : public CCommandProcessorFragment_GLBase std::array aAttributeDescriptions = {}; aAttributeDescriptions[0] = {0, 0, VK_FORMAT_R32G32_SFLOAT, 0}; if(HasSampler) - aAttributeDescriptions[1] = {1, 0, VK_FORMAT_R8G8B8A8_UINT, sizeof(float) * 2}; + aAttributeDescriptions[1] = {1, 0, VK_FORMAT_R32G32B32A32_SFLOAT, sizeof(float) * 2}; std::array aSetLayouts; aSetLayouts[0] = m_Standard3DTexturedDescriptorSetLayout; @@ -5091,7 +5091,7 @@ class CCommandProcessorFragment_Vulkan : public CCommandProcessorFragment_GLBase aPushConstants[0] = {VK_SHADER_STAGE_VERTEX_BIT, 0, VertPushConstantSize}; aPushConstants[1] = {VK_SHADER_STAGE_FRAGMENT_BIT, sizeof(SUniformTileGPosBorder) + sizeof(SUniformTileGVertColorAlign), FragPushConstantSize}; - return CreateGraphicsPipeline(pVertName, pFragName, PipeContainer, HasSampler ? (sizeof(float) * 2 + sizeof(uint8_t) * 4) : (sizeof(float) * 2), aAttributeDescriptions, aSetLayouts, aPushConstants, TexMode, BlendMode, DynamicMode); + return CreateGraphicsPipeline(pVertName, pFragName, PipeContainer, HasSampler ? (sizeof(float) * 6) : (sizeof(float) * 2), aAttributeDescriptions, aSetLayouts, aPushConstants, TexMode, BlendMode, DynamicMode); } template diff --git a/src/engine/client/graphics_threaded.cpp b/src/engine/client/graphics_threaded.cpp index 0c7c111ba1..32ef822d89 100644 --- a/src/engine/client/graphics_threaded.cpp +++ b/src/engine/client/graphics_threaded.cpp @@ -1022,6 +1022,33 @@ void CGraphics_Threaded::QuadsDrawFreeform(const CFreeformItem *pArray, int Num) } } +void CGraphics_Threaded::QuadsTex3DDrawFreeform(const CFreeformItem *pArray, int Num) +{ + dbg_assert(m_Drawing == EDrawing::QUADS, "freeform texture array draw without begin"); + const bool Triangles = g_Config.m_GfxQuadAsTriangle && !m_GLUseTrianglesAsQuad; + const int Count = Triangles ? 6 : 4; + const int QuadOrder[] = {0, 1, 3, 2}; + const int TriangleOrder[] = {0, 1, 3, 0, 3, 2}; + const float Index = Uses2DTextureArrays() ? m_CurIndex : (m_CurIndex + 0.5f) / 256.0f; + for(int I = 0; I < Num; ++I) + { + const auto &Q = pArray[I]; + const vec2 P[] = {vec2(Q.m_X0, Q.m_Y0), vec2(Q.m_X1, Q.m_Y1), vec2(Q.m_X2, Q.m_Y2), vec2(Q.m_X3, Q.m_Y3)}; + for(int K = 0; K < Count; ++K) + { + const int Source = Triangles ? TriangleOrder[K] : QuadOrder[K]; + auto &V = m_aVerticesTex3D[m_NumVertices + K]; + V.m_Pos.x = P[Source].x; + V.m_Pos.y = P[Source].y; + V.m_Tex.u = m_aTexture[Source].u; + V.m_Tex.v = m_aTexture[Source].v; + V.m_Tex.w = Index; + V.m_Color = m_aColor[Source]; + } + AddVertices(Count, m_aVerticesTex3D); + } +} + void CGraphics_Threaded::QuadsText(float x, float y, float Size, const char *pText) { float StartX = x; diff --git a/src/engine/client/graphics_threaded.h b/src/engine/client/graphics_threaded.h index 0a78d37a53..3e9adc54f8 100644 --- a/src/engine/client/graphics_threaded.h +++ b/src/engine/client/graphics_threaded.h @@ -1096,6 +1096,7 @@ class CGraphics_Threaded : public IEngineGraphics void QuadsDrawTL(const CQuadItem *pArray, int Num) override; void QuadsTex3DDrawTL(const CQuadItem *pArray, int Num) override; + void QuadsTex3DDrawFreeform(const CFreeformItem *pArray, int Num) override; void QuadsDrawFreeform(const CFreeformItem *pArray, int Num) override; void QuadsText(float x, float y, float Size, const char *pText) override; diff --git a/src/engine/graphics.h b/src/engine/graphics.h index 490bd3388c..58d1427870 100644 --- a/src/engine/graphics.h +++ b/src/engine/graphics.h @@ -62,10 +62,10 @@ class CGraphicTile class CGraphicTileTextureCoords { public: - ubvec4 m_TexCoordTopLeft; - ubvec4 m_TexCoordTopRight; - ubvec4 m_TexCoordBottomRight; - ubvec4 m_TexCoordBottomLeft; + vec4 m_TexCoordTopLeft; + vec4 m_TexCoordTopRight; + vec4 m_TexCoordBottomRight; + vec4 m_TexCoordBottomLeft; }; /* @@ -510,6 +510,7 @@ class IGraphics : public IInterface virtual void RenderQuadContainerAsSpriteMultiple(int ContainerIndex, int QuadOffset, int DrawCount, SRenderSpriteInfo *pRenderInfo) = 0; virtual void QuadsDrawFreeform(const CFreeformItem *pArray, int Num) = 0; + virtual void QuadsTex3DDrawFreeform(const CFreeformItem *pArray, int Num) = 0; virtual void QuadsText(float x, float y, float Size, const char *pText) = 0; // sprites diff --git a/src/engine/shared/config_variables_bestclient.h b/src/engine/shared/config_variables_bestclient.h index f5d28c8e06..685dfc486f 100644 --- a/src/engine/shared/config_variables_bestclient.h +++ b/src/engine/shared/config_variables_bestclient.h @@ -49,6 +49,8 @@ MACRO_CONFIG_INT(BcCustomAspectRatioNum, bc_custom_aspect_ratio_num, 0, 0, 10000 MACRO_CONFIG_INT(BcCustomAspectRatioDen, bc_custom_aspect_ratio_den, 0, 0, 100000, CFGFLAG_CLIENT | CFGFLAG_SAVE, "Custom aspect ratio denominator (height), 0=unset") // Visuals QoL +MACRO_CONFIG_INT(BcEntitiesRounding, bc_entities_rounding, 0, 0, 100, CFGFLAG_CLIENT | CFGFLAG_SAVE, "Entity contour rounding in percent (0 = square, 100 = circle)") +MACRO_CONFIG_INT(BcEntitiesRoundingMode, bc_entities_rounding_mode, 2, 0, 2, CFGFLAG_CLIENT | CFGFLAG_SAVE, "Entity rounding corners (0 = outer, 1 = inner, 2 = both)") MACRO_CONFIG_INT(BcShowRealHitbox, bc_show_real_hitbox, 0, 0, 1, CFGFLAG_CLIENT | CFGFLAG_SAVE, "Show a dot at the center of your real hitbox") MACRO_CONFIG_COL(BcShowRealHitboxColor, bc_show_real_hitbox_color, 0xFFFFFFFFU, CFGFLAG_CLIENT | CFGFLAG_SAVE | CFGFLAG_COLALPHA, "Color of the real hitbox center dot") MACRO_CONFIG_INT(BcShowhudDummyCoordIndicator, bc_showhud_dummy_coord_indicator, 0, 0, 1, CFGFLAG_CLIENT | CFGFLAG_SAVE, "Show player-below indicator when aligned above another player") diff --git a/src/game/client/components/tclient/menus_tclient.cpp b/src/game/client/components/tclient/menus_tclient.cpp index 58ba94ca62..0ea3559479 100644 --- a/src/game/client/components/tclient/menus_tclient.cpp +++ b/src/game/client/components/tclient/menus_tclient.cpp @@ -1013,6 +1013,22 @@ void CMenus::RenderSettingsTClientSettings(CUIRect MainView) DoButton_CheckBoxAutoVMarginAndSet(&g_Config.m_TcOutline, TCLocalize("Show any enabled outlines"), &g_Config.m_TcOutline, &Column, LineSize); DoButton_CheckBoxAutoVMarginAndSet(&g_Config.m_TcOutlineEntities, TCLocalize("Only show outlines in entities"), &g_Config.m_TcOutlineEntities, &Column, LineSize); + Column.HSplitTop(LineSize, &Button, &Column); + Ui()->DoScrollbarOption(&g_Config.m_BcEntitiesRounding, &g_Config.m_BcEntitiesRounding, &Button, TCLocalize("Entity rounding (%)"), 0, 100); + Column.HSplitTop(LineSize, &Button, &Column); + { + static CButtonContainer s_aCornerModes[3]; + const char *apNames[] = {TCLocalize("Outer corners"), TCLocalize("Inner corners"), TCLocalize("All corners")}; + const float Width = Button.w / 3.0f; + for(int Mode = 0; Mode < 3; ++Mode) + { + CUIRect Tab; + Button.VSplitLeft(Width, &Tab, &Button); + if(DoButton_MenuTab(&s_aCornerModes[Mode], apNames[Mode], g_Config.m_BcEntitiesRoundingMode == Mode, &Tab, Mode == 0 ? IGraphics::CORNER_L : Mode == 2 ? IGraphics::CORNER_R : + IGraphics::CORNER_NONE)) + g_Config.m_BcEntitiesRoundingMode = Mode; + } + } auto DoOutlineType = [&](CButtonContainer &ButtonContainer, const char *pName, int &Enable, int &Width, unsigned int &Color, const unsigned int &ColorDefault) { // Checkbox & Color diff --git a/src/game/client/components/tclient/outlines.cpp b/src/game/client/components/tclient/outlines.cpp index 6aa127f00d..8a03a18055 100644 --- a/src/game/client/components/tclient/outlines.cpp +++ b/src/game/client/components/tclient/outlines.cpp @@ -21,117 +21,25 @@ enum OUTLINE_SOLID, }; -enum class OutlineLayer +void COutlines::ClearRoundedCache() { - GAME, - FRONT, - TELE -}; + for(auto &[Key, Container] : m_RoundedContainers) + Graphics()->DeleteQuadContainer(Container.first); + m_RoundedContainers.clear(); +} -class COutLineLayer +void COutlines::OnShutdown() { -private: - CMapItemLayerTilemap *GetLayer(CGameClient *pThis) const - { - if(m_Type == OutlineLayer::GAME) - return pThis->Layers()->GameLayer(); - if(m_Type == OutlineLayer::FRONT) - return pThis->Layers()->FrontLayer(); - if(m_Type == OutlineLayer::TELE) - return pThis->Layers()->TeleLayer(); - dbg_assert(false, "Invalid value for m_Type"); - } - int GetLayerData(CGameClient *pThis) const - { - if(m_Type == OutlineLayer::GAME) - return pThis->Layers()->GameLayer()->m_Data; - if(m_Type == OutlineLayer::FRONT) - return pThis->Layers()->FrontLayer()->m_Front; - if(m_Type == OutlineLayer::TELE) - return pThis->Layers()->TeleLayer()->m_Tele; - dbg_assert(false, "Invalid value for m_Type"); - } - -public: - const OutlineLayer m_Type; - void GetMeta(CGameClient *pThis, ivec2 &Size) const - { - Size = {0, 0}; - const auto *pLayer = GetLayer(pThis); - if(!pLayer) - return; - const size_t TileSize = m_Type == OutlineLayer::TELE ? sizeof(CTeleTile) : sizeof(CTile); - const int DataSize = pThis->Layers()->Map()->GetDataSize(GetLayerData(pThis)); - if(DataSize <= 0 || (size_t)DataSize < (size_t)pLayer->m_Width * (size_t)pLayer->m_Height * TileSize) - return; - Size = {pLayer->m_Width, pLayer->m_Height}; - } - void SetData(CGameClient *pThis, int *pData, const ivec2 &Size) const - { - const auto *pLayer = GetLayer(pThis); - const auto *pTiles = (CTile *)pThis->Layers()->Map()->GetData(GetLayerData(pThis)); - for(int y = 0; y < pLayer->m_Height; ++y) - { - for(int x = 0; x < pLayer->m_Width; ++x) - { - const int Index = y * pLayer->m_Width + x; - const int IndexOut = y * Size.x + x; - if(m_Type == OutlineLayer::TELE) - { - const auto &Tile = ((CTeleTile *)pTiles)[Index]; - if(Tile.m_Number != 0 && Tile.m_Type != 0) - pData[IndexOut] = OUTLINE_TELE; - } - else - { - const auto Tile = pTiles[Index].m_Index; - if(Tile == TILE_SOLID || Tile == TILE_NOHOOK) - pData[IndexOut] = OUTLINE_SOLID; - else if(Tile == TILE_FREEZE || Tile == TILE_DFREEZE || Tile == TILE_LFREEZE) - pData[IndexOut] = OUTLINE_FREEZE; - else if(Tile == TILE_UNFREEZE || Tile == TILE_DUNFREEZE || Tile == TILE_LUNFREEZE) - pData[IndexOut] = OUTLINE_UNFREEZE; - else if(Tile == TILE_DEATH) - pData[IndexOut] = OUTLINE_KILL; - } - } - } - } -}; - -// The order of this determines order of priority into the one map (tele + freeze = tele) -static constexpr COutLineLayer OUTLINE_LAYERS[] = {{OutlineLayer::TELE}, {OutlineLayer::GAME}, {OutlineLayer::FRONT}}; + ClearRoundedCache(); +} void COutlines::OnMapLoad() { - if(m_pMapData) - { - delete[] m_pMapData; - m_pMapData = nullptr; - } - - // Find valid layers and size - std::vector vValidOutlineLayers; - m_MapDataSize = {0, 0}; - for(const auto &Layer : OUTLINE_LAYERS) - { - ivec2 LayerSize; - Layer.GetMeta(GameClient(), LayerSize); - if(LayerSize.x <= 0 || LayerSize.y <= 0) - continue; - m_MapDataSize.x = std::max(m_MapDataSize.x, LayerSize.x); - m_MapDataSize.y = std::max(m_MapDataSize.y, LayerSize.y); - vValidOutlineLayers.push_back(&Layer); - } - if(m_MapDataSize.x <= 0 || m_MapDataSize.y <= 0) - return; - m_pMapData = new int[m_MapDataSize.x * m_MapDataSize.y](OUTLINE_NONE); - - // Do it - for(const auto *pLayer : vValidOutlineLayers) - { - pLayer->SetData(GameClient(), m_pMapData, m_MapDataSize); - } + ClearRoundedCache(); + m_Regions.Load(Layers()); + m_OutlineIndex.Build(m_Regions); + m_MapDataSize = ivec2(m_Regions.m_Width, m_Regions.m_Height); + m_pMapData = m_Regions.m_vTypes.empty() ? nullptr : m_Regions.m_vTypes.data(); } void COutlines::OnRender() @@ -148,6 +56,7 @@ void COutlines::OnRender() const float Scale = 32.0f; CScreenRect ScreenRect = Graphics()->GetScreen(); + const float PixelsPerUnit = Graphics()->ScreenWidth() / std::max(1.0f, ScreenRect.Width()); // bestclient if(GameClient()->OptimizerFpsFogEnabled()) @@ -173,7 +82,7 @@ void COutlines::OnRender() int EndX = (int)(ScreenRect.m_BottomRight.x / Scale) + 1; int EndY = (int)(ScreenRect.m_BottomRight.y / Scale) + 1; int MaxScale = 12; - if(EndX - StartX > Graphics()->ScreenWidth() / MaxScale || EndY - StartY > Graphics()->ScreenHeight() / MaxScale) + if(g_Config.m_BcEntitiesRounding == 0 && (EndX - StartX > Graphics()->ScreenWidth() / MaxScale || EndY - StartY > Graphics()->ScreenHeight() / MaxScale)) { int EdgeX = (EndX - StartX) - (Graphics()->ScreenWidth() / MaxScale); StartX += EdgeX / 2; @@ -189,7 +98,23 @@ void COutlines::OnRender() return m_pMapData[y * m_MapDataSize.x + x]; }; + const bool Rounded = g_Config.m_BcEntitiesRounding > 0; + const int Steps = RoundedTiles::Detail(g_Config.m_BcEntitiesRounding * 0.16f, PixelsPerUnit); + if(m_RoundingPercent != g_Config.m_BcEntitiesRounding || m_RoundingMode != g_Config.m_BcEntitiesRoundingMode || m_RoundingSteps != Steps) + { + ClearRoundedCache(); + m_RoundingPercent = g_Config.m_BcEntitiesRounding; + m_RoundingMode = g_Config.m_BcEntitiesRoundingMode; + m_RoundingSteps = Steps; + } Graphics()->TextureClear(); + Graphics()->BlendNormal(); + Graphics()->QuadsSetRotation(0); + if(Rounded) + { + RenderRoundedOutlines(StartX, StartY, EndX, EndY); + return; + } Graphics()->QuadsBegin(); for(int y = StartY; y < EndY; y++) @@ -197,7 +122,9 @@ void COutlines::OnRender() for(int x = StartX; x < EndX; x++) { const int Type = GetTile(x, y); - if(Type == OUTLINE_NONE) + // Switch tiles cover earlier entity layers but have no configurable + // outline. They still block their neighbors' rounded contours. + if(Type == OUTLINE_NONE || Type == CEntityRegions::SWITCH) continue; class COutlineConfig { @@ -283,3 +210,66 @@ void COutlines::OnRender() Graphics()->QuadsEnd(); } + +void COutlines::RenderRoundedOutlines(int StartX, int StartY, int EndX, int EndY) +{ + m_OutlineIndex.Visit(StartX, StartY, EndX, EndY, [&](const CEntityOutlineIndex::CCell &Cell, vec2 Position, vec2 Scale) { + int Enable = 0, Width = 0; + unsigned Color = 0; + if(Cell.m_Type == OUTLINE_SOLID) + { + Enable = g_Config.m_TcOutlineSolid; + Width = g_Config.m_TcOutlineWidthSolid; + Color = g_Config.m_TcOutlineColorSolid; + } + else if(Cell.m_Type == OUTLINE_FREEZE) + { + Enable = g_Config.m_TcOutlineFreeze; + Width = g_Config.m_TcOutlineWidthFreeze; + Color = g_Config.m_TcOutlineColorFreeze; + } + else if(Cell.m_Type == OUTLINE_UNFREEZE) + { + Enable = g_Config.m_TcOutlineUnfreeze; + Width = g_Config.m_TcOutlineWidthUnfreeze; + Color = g_Config.m_TcOutlineColorUnfreeze; + } + else if(Cell.m_Type == OUTLINE_KILL) + { + Enable = g_Config.m_TcOutlineKill; + Width = g_Config.m_TcOutlineWidthKill; + Color = g_Config.m_TcOutlineColorKill; + } + else if(Cell.m_Type == OUTLINE_TELE) + { + Enable = g_Config.m_TcOutlineTele; + Width = g_Config.m_TcOutlineWidthTele; + Color = g_Config.m_TcOutlineColorTele; + } + if(!Enable || Width <= 0) + return; + const uint64_t Key = Cell.Key() | (uint64_t(Width) << 24); + auto It = m_RoundedContainers.find(Key); + if(It == m_RoundedContainers.end()) + { + auto Shape = RoundedTiles::Build(Cell.m_Mask, m_RoundingPercent * 0.16f, m_RoundingMode, m_RoundingSteps, Cell.m_Blockers, Cell.m_Transition); + RoundedTiles::RemoveLowerPriorityEdges(Shape, Cell.m_HigherMask); + const auto Triangles = RoundedTiles::Outline(Shape, Width); + std::vector vItems; + vItems.reserve(Triangles.size()); + for(const auto &T : Triangles) + vItems.emplace_back(T[0], T[1], T[2], T[2]); + Graphics()->SetColor(1, 1, 1, 1); + const int Container = Graphics()->CreateQuadContainer(false); + if(!vItems.empty()) + Graphics()->QuadContainerAddQuads(Container, vItems.data(), vItems.size()); + Graphics()->QuadContainerUpload(Container); + It = m_RoundedContainers.emplace(Key, std::make_pair(Container, (int)vItems.size())).first; + } + Graphics()->SetColor(color_cast(ColorHSLA(Color, true))); + for(int Offset = 0; Offset < It->second.second; Offset += 1024) + { + Graphics()->RenderQuadContainerEx(It->second.first, Offset, std::min(1024, It->second.second - Offset), Position.x, Position.y, Scale.x, Scale.y); + } + }); +} diff --git a/src/game/client/components/tclient/outlines.h b/src/game/client/components/tclient/outlines.h index b40064f1d0..9ce4f856fa 100644 --- a/src/game/client/components/tclient/outlines.h +++ b/src/game/client/components/tclient/outlines.h @@ -2,6 +2,12 @@ #define GAME_CLIENT_COMPONENTS_TCLIENT_OUTLINES_H #include +#include +#include +#include + +#include +#include class CTile; class CTeleTile; @@ -11,12 +17,18 @@ class COutlines : public CComponent private: ivec2 m_MapDataSize; int *m_pMapData = nullptr; + CEntityRegions m_Regions; + CEntityOutlineIndex m_OutlineIndex; + void RenderRoundedOutlines(int StartX, int StartY, int EndX, int EndY); + std::unordered_map> m_RoundedContainers; + int m_RoundingPercent = -1, m_RoundingMode = -1, m_RoundingSteps = 2; + void ClearRoundedCache(); public: int Sizeof() const override { return sizeof(*this); } void OnMapLoad() override; void OnRender() override; - ~COutlines() override { delete[] m_pMapData; } + void OnShutdown() override; }; #endif diff --git a/src/game/map/entity_outline.h b/src/game/map/entity_outline.h new file mode 100644 index 0000000000..44c092fc3b --- /dev/null +++ b/src/game/map/entity_outline.h @@ -0,0 +1,106 @@ +#ifndef GAME_MAP_ENTITY_OUTLINE_H +#define GAME_MAP_ENTITY_OUTLINE_H + +#include "entity_regions.h" + +#include + +#include +#include + +// Index only boundary cells. Zooming out must not rescan empty space or the +// interiors of filled regions, nor repeat clamped border cells one by one. +class CEntityOutlineIndex +{ +public: + struct CCell + { + ivec2 m_Position; + int m_Type; + unsigned m_Mask, m_Blockers, m_Transition, m_HigherMask; + + uint64_t Key() const + { + return uint64_t(m_Mask) | (uint64_t(m_Blockers) << 8) | (uint64_t(m_HigherMask) << 16) | (uint64_t(m_Transition) << 40); + } + }; + + void Build(const CEntityRegions &Regions) + { + m_Width = Regions.m_Width; + m_Height = Regions.m_Height; + m_vRows.clear(); + m_vLeft.clear(); + m_vRight.clear(); + m_vTop.clear(); + m_vBottom.clear(); + if(m_Width <= 0 || m_Height <= 0 || Regions.m_vTypes.empty()) + return; + m_vRows.resize(m_Height); + auto AddCell = [&](std::vector &vCells, int X, int Y) { + const int Type = Regions.Get(X, Y); + if(Type == CEntityRegions::NONE || Type == CEntityRegions::SWITCH) + return; + const unsigned Mask = Regions.Mask(X, Y, Type); + if(Mask == 255) + return; + vCells.push_back({ivec2(X, Y), Type, Mask, Regions.BlockerMask(X, Y, Type), Regions.TransitionCorners(X, Y), Regions.PriorityMask(X, Y, Type) & ~Mask}); + }; + for(int Y = 0; Y < m_Height; ++Y) + { + for(int X = 0; X < m_Width; ++X) + AddCell(m_vRows[Y], X, Y); + AddCell(m_vLeft, -1, Y); + AddCell(m_vRight, m_Width, Y); + } + for(int X = 0; X < m_Width; ++X) + { + AddCell(m_vTop, X, -1); + AddCell(m_vBottom, X, m_Height); + } + } + + // Bounds are in tile coordinates, with exclusive right/bottom edges. + // Outside a map side the old renderer repeats a clamped row/column. Its + // contours are straight, so one scaled copy preserves the same coverage. + template + void Visit(int StartX, int StartY, int EndX, int EndY, F &&Draw) const + { + if(m_vRows.empty() || StartX >= EndX || StartY >= EndY) + return; + const int X0 = std::max(0, StartX), X1 = std::min(m_Width, EndX); + const int Y0 = std::max(0, StartY), Y1 = std::min(m_Height, EndY); + for(int Y = Y0; Y < Y1; ++Y) + { + const auto &vRow = m_vRows[Y]; + auto It = std::lower_bound(vRow.begin(), vRow.end(), X0, [](const CCell &Cell, int X) { return Cell.m_Position.x < X; }); + for(; It != vRow.end() && It->m_Position.x < X1; ++It) + Draw(*It, vec2(It->m_Position.x * 32.0f, It->m_Position.y * 32.0f), vec2(1, 1)); + } + auto Vertical = [&](const std::vector &vCells, float X, float Width) { + auto It = std::lower_bound(vCells.begin(), vCells.end(), Y0, [](const CCell &Cell, int Y) { return Cell.m_Position.y < Y; }); + for(; It != vCells.end() && It->m_Position.y < Y1; ++It) + Draw(*It, vec2(X * 32.0f, It->m_Position.y * 32.0f), vec2(Width, 1)); + }; + auto Horizontal = [&](const std::vector &vCells, float Y, float Height) { + auto It = std::lower_bound(vCells.begin(), vCells.end(), X0, [](const CCell &Cell, int X) { return Cell.m_Position.x < X; }); + for(; It != vCells.end() && It->m_Position.x < X1; ++It) + Draw(*It, vec2(It->m_Position.x * 32.0f, Y * 32.0f), vec2(1, Height)); + }; + if(StartX < 0) + Vertical(m_vLeft, StartX, float(std::min(EndX, 0)) - StartX); + if(EndX > m_Width) + Vertical(m_vRight, std::max(StartX, m_Width), float(EndX) - std::max(StartX, m_Width)); + if(StartY < 0) + Horizontal(m_vTop, StartY, float(std::min(EndY, 0)) - StartY); + if(EndY > m_Height) + Horizontal(m_vBottom, std::max(StartY, m_Height), float(EndY) - std::max(StartY, m_Height)); + } + +private: + int m_Width = 0, m_Height = 0; + std::vector> m_vRows; + std::vector m_vLeft, m_vRight, m_vTop, m_vBottom; +}; + +#endif diff --git a/src/game/map/entity_regions.h b/src/game/map/entity_regions.h new file mode 100644 index 0000000000..be471b29f7 --- /dev/null +++ b/src/game/map/entity_regions.h @@ -0,0 +1,277 @@ +#ifndef GAME_MAP_ENTITY_REGIONS_H +#define GAME_MAP_ENTITY_REGIONS_H + +#include + +#include +#include + +#include +#include + +// Shared category/priority map for entity geometry and outlines. +class CEntityRegions +{ +public: + enum + { + NONE, + UNFREEZE, + FREEZE, + TELE, + KILL, + SOLID, + SWITCH + }; + int m_Width = 0, m_Height = 0; + std::vector m_vTypes; + std::vector m_vRegionKeys; + std::vector m_vTopLayers; + static bool IsThrough(int Index) + { + return Index == TILE_THROUGH || Index == TILE_THROUGH_CUT || Index == TILE_THROUGH_ALL || Index == TILE_THROUGH_DIR; + } + + static int Category(int Index) + { + if(Index == TILE_SOLID || Index == TILE_NOHOOK) + return SOLID; + if(Index == TILE_FREEZE || Index == TILE_DFREEZE || Index == TILE_LFREEZE) + return FREEZE; + if(Index == TILE_UNFREEZE || Index == TILE_DUNFREEZE || Index == TILE_LUNFREEZE) + return UNFREEZE; + return Index == TILE_DEATH ? KILL : NONE; + } + + static int VisibleType(int GameIndex, int FrontIndex, int TeleType, int SwitchType = 0) + { + // Entity layers are rendered game -> front -> tele -> switch. A later + // tile owns the visible silhouette even when it has no outline of its + // own; otherwise a covered unfreeze tile leaves corner fragments. + int Type = Category(GameIndex); + if(const int Front = Category(FrontIndex); Front != NONE) + Type = Front; + if(TeleType != 0) + Type = TELE; + if(SwitchType != 0) + Type = SWITCH; + return Type; + } + + static int VisibleRegionKey(int GameIndex, int FrontIndex, int TeleType, int SwitchType = 0) + { + // A later entity layer can cover only some cells of an earlier one. + // Keep that seam out of a single region: an inner junction spanning it + // otherwise exposes a small piece of the buried asset at its corner. + int Type = Category(GameIndex); + int Underlay = NONE; + int AssetType = 0; + if(const int Front = Category(FrontIndex); Front != NONE) + { + Underlay = Type; + Type = Front; + } + if(TeleType != 0) + { + Underlay = Type; + Type = TELE; + AssetType = TeleType; + } + if(SwitchType != 0) + { + Underlay = Type; + Type = SWITCH; + AssetType = SwitchType; + } + // Adjacent tele/switch types can have different tile artwork. Their + // shared edge stays straight, but cannot seed an inner arc for either. + return Type * 2048 + AssetType * 8 + Underlay; + } + + int Get(int X, int Y) const + { + if(m_vTypes.empty()) + return NONE; + return m_vTypes[(size_t)std::clamp(Y, 0, m_Height - 1) * m_Width + std::clamp(X, 0, m_Width - 1)]; + } + + int RegionKey(int X, int Y) const + { + if(m_vRegionKeys.empty()) + return Get(X, Y); + return m_vRegionKeys[(size_t)std::clamp(Y, 0, m_Height - 1) * m_Width + std::clamp(X, 0, m_Width - 1)]; + } + + int TopLayer(int X, int Y) const + { + if(m_vTopLayers.empty()) + return 0; + return m_vTopLayers[(size_t)std::clamp(Y, 0, m_Height - 1) * m_Width + std::clamp(X, 0, m_Width - 1)]; + } + + bool ShouldRoundTile(int X, int Y, int Layer, int Index) const + { + if(Index == 0) + return false; + const int Type = Get(X, Y); + // A front-layer through tile is translucent artwork laid over the game + // tile. Both layers must use the same silhouette, otherwise the square + // front sprite crosses the curved solid outline (or exposes a wedge). + if(Layer == 1 && TopLayer(X, Y) == 0 && Type != NONE && IsThrough(Index)) + return true; + if(Layer != TopLayer(X, Y)) + return false; + if(Layer == 2) + return Type == TELE; + if(Layer == 3) + return Type == SWITCH; + return Type != NONE && Category(Index) == Type; + } + + // A three-against-one material junction has one shared circular boundary: + // the lone tile loses its convex corner and the other material fills it. + // Limit this to different visible categories. Two teleporter assets, or a + // change in buried underlay, must keep their existing straight seam. + // Low four bits: the lone convex or diagonal concave tile. High four + // bits: the two side tiles whose straight contour ends at the arc tangent. + unsigned TransitionCorners(int X, int Y) const + { + const int Here = RegionKey(X, Y), Type = Get(X, Y); + if(Type == NONE) + return 0; + unsigned Result = 0; + constexpr int SX[] = {-1, 1, 1, -1}; + constexpr int SY[] = {-1, -1, 1, 1}; + for(int Corner = 0; Corner < 4; ++Corner) + { + const int H = RegionKey(X + SX[Corner], Y); + const int V = RegionKey(X, Y + SY[Corner]); + const int D = RegionKey(X + SX[Corner], Y + SY[Corner]); + const bool Inner = H == Here && V == Here && D != Here && Get(X + SX[Corner], Y + SY[Corner]) != Type; + const bool Outer = H == V && V == D && H != Here && Get(X + SX[Corner], Y) != NONE && Get(X + SX[Corner], Y) != Type; + const bool Trim = D == Here && ((H == Here && V != Here && Get(X, Y + SY[Corner]) != Type) || + (V == Here && H != Here && Get(X + SX[Corner], Y) != Type)); + if(Inner || Outer) + Result |= 1u << Corner; + if(Trim) + Result |= 1u << (Corner + 4); + } + return Result; + } + + unsigned ComplementCorners(int X, int Y) const { return TransitionCorners(X, Y) & 15u; } + + unsigned Mask(int X, int Y, int Type) const + { + unsigned Result = 0, Bit = 0; + for(int DY = -1; DY <= 1; ++DY) + for(int DX = -1; DX <= 1; ++DX) + if(DX || DY) + { + // A different outlined category has its own silhouette. Treating it + // as filled creates a straight edge on one asset and a rounded edge + // on the other (and leaves small wedges at their junction). + if(RegionKey(X + DX, Y + DY) == RegionKey(X, Y)) + Result |= 1u << Bit; + ++Bit; + } + return Result; + } + + unsigned PriorityMask(int X, int Y, int Type) const + { + unsigned Result = 0, Bit = 0; + for(int DY = -1; DY <= 1; ++DY) + for(int DX = -1; DX <= 1; ++DX) + if(DX || DY) + { + if(Get(X + DX, Y + DY) >= Type) + Result |= 1u << Bit; + ++Bit; + } + return Result; + } + + unsigned BlockerMask(int X, int Y, int Type) const + { + unsigned Result = 0, Bit = 0; + for(int DY = -1; DY <= 1; ++DY) + for(int DX = -1; DX <= 1; ++DX) + if(DX || DY) + { + const int Neighbor = Get(X + DX, Y + DY); + if(Neighbor != NONE && RegionKey(X + DX, Y + DY) != RegionKey(X, Y)) + Result |= 1u << Bit; + ++Bit; + } + return Result; + } + + void Load(CLayers *pLayers) + { + m_Width = m_Height = 0; + m_vTypes.clear(); + m_vRegionKeys.clear(); + m_vTopLayers.clear(); + struct CLayer + { + CMapItemLayerTilemap *m_pLayer; + int m_Data; + bool m_Tele; + bool m_Switch; + }; + const auto *pTele = pLayers->TeleLayer(); + const auto *pGame = pLayers->GameLayer(); + const auto *pFront = pLayers->FrontLayer(); + const auto *pSwitch = pLayers->SwitchLayer(); + // Follow the visible entity rendering order. Later layers cover earlier + // ones, so their category must own the rounded shape and outline. + CLayer Layers[] = {{pLayers->GameLayer(), pGame ? pGame->m_Data : -1, false, false}, + {pLayers->FrontLayer(), pFront ? pFront->m_Front : -1, false, false}, + {pLayers->TeleLayer(), pTele ? pTele->m_Tele : -1, true, false}, + {pLayers->SwitchLayer(), pSwitch ? pSwitch->m_Switch : -1, false, true}}; + for(auto &L : Layers) + { + if(!L.m_pLayer) + continue; + const int W = L.m_pLayer->m_Width, H = L.m_pLayer->m_Height; + const size_t TileSize = L.m_Tele ? sizeof(CTeleTile) : (L.m_Switch ? sizeof(CSwitchTile) : sizeof(CTile)); + const int Bytes = pLayers->Map()->GetDataSize(L.m_Data); + if(W <= 0 || H <= 0 || Bytes <= 0 || (uint64_t)W * H * TileSize > (uint64_t)Bytes) + { + L.m_pLayer = nullptr; + continue; + } + m_Width = std::max(m_Width, W); + m_Height = std::max(m_Height, H); + } + m_vTypes.resize((size_t)m_Width * m_Height, NONE); + m_vRegionKeys.resize((size_t)m_Width * m_Height, NONE); + m_vTopLayers.resize((size_t)m_Width * m_Height, 0); + const void *pData[] = { + Layers[0].m_pLayer ? pLayers->Map()->GetData(Layers[0].m_Data) : nullptr, + Layers[1].m_pLayer ? pLayers->Map()->GetData(Layers[1].m_Data) : nullptr, + Layers[2].m_pLayer ? pLayers->Map()->GetData(Layers[2].m_Data) : nullptr, + Layers[3].m_pLayer ? pLayers->Map()->GetData(Layers[3].m_Data) : nullptr}; + auto TileIndex = [&](int Layer, int X, int Y) { + const auto &L = Layers[Layer]; + if(!pData[Layer] || !L.m_pLayer || X >= L.m_pLayer->m_Width || Y >= L.m_pLayer->m_Height) + return 0; + const size_t I = (size_t)Y * L.m_pLayer->m_Width + X; + return L.m_Tele ? int(static_cast(pData[Layer])[I].m_Type) : (L.m_Switch ? int(static_cast(pData[Layer])[I].m_Type) : int(static_cast(pData[Layer])[I].m_Index)); + }; + for(int Y = 0; Y < m_Height; ++Y) + for(int X = 0; X < m_Width; ++X) + { + const int Game = TileIndex(0, X, Y), Front = TileIndex(1, X, Y), Tele = TileIndex(2, X, Y), Switch = TileIndex(3, X, Y); + const size_t I = (size_t)Y * m_Width + X; + m_vTypes[I] = VisibleType(Game, Front, Tele, Switch); + m_vRegionKeys[I] = VisibleRegionKey(Game, Front, Tele, Switch); + m_vTopLayers[I] = Switch ? 3 : Tele ? 2 : + Category(Front) != NONE ? 1 : + 0; + } + } +}; + +#endif diff --git a/src/game/map/map_renderer.cpp b/src/game/map/map_renderer.cpp index 0ac54fed18..ce47178db3 100644 --- a/src/game/map/map_renderer.cpp +++ b/src/game/map/map_renderer.cpp @@ -20,6 +20,8 @@ void CMapRenderer::Load(ERenderType Type, CLayers *pLayers, IMapImages *pMapImag { Clear(); + auto pEntityRegions = std::make_shared(); + pEntityRegions->Load(pLayers); std::shared_ptr pEnvelopeManager = std::make_shared(pEnvelopeEval, pLayers->Map()); bool PassedGameLayer = false; @@ -140,6 +142,8 @@ void CMapRenderer::Load(ERenderType Type, CLayers *pLayers, IMapImages *pMapImag // just ignore invalid layers from rendering if(pRenderLayer) { + if(LayerType == LAYER_GAME || LayerType == LAYER_FRONT || LayerType == LAYER_TELE || LayerType == LAYER_SWITCH) + static_cast(pRenderLayer.get())->SetEntityRegions(pEntityRegions, LayerType == LAYER_TELE, LayerType == LAYER_GAME, LayerType == LAYER_SWITCH); pRenderLayer->OnInit(Graphics(), TextRender(), RenderMap(), pEnvelopeManager, pLayers->Map(), pMapImages, CallbackLayerInitOptional); if(pRenderLayer->IsValid()) { diff --git a/src/game/map/render_layer.cpp b/src/game/map/render_layer.cpp index 9c97abb6f3..8b2279e6f4 100644 --- a/src/game/map/render_layer.cpp +++ b/src/game/map/render_layer.cpp @@ -341,6 +341,13 @@ void CRenderLayerTile::RenderTileLayer(const ColorRGBA &Color, const CRenderLaye int ScreenRectX0 = std::floor(ScreenRect.m_TopLeft.x / 32); int ScreenRectY1 = std::ceil(ScreenRect.m_BottomRight.y / 32); int ScreenRectX1 = std::ceil(ScreenRect.m_BottomRight.x / 32); + if(m_pEntityRegions && m_RoundingPercent > 0 && !pTileLayerVisuals) + { + --ScreenRectX0; + --ScreenRectY0; + ++ScreenRectX1; + ++ScreenRectY1; + } if(IsVisibleInClipRegion(m_LayerClip)) { @@ -360,7 +367,8 @@ void CRenderLayerTile::RenderTileLayer(const ColorRGBA &Color, const CRenderLaye size_t EndIndex = Y1 * Visuals.m_Width - 1; const auto &Start = Visuals.m_vTilesOfLayer[StartIndex]; const auto &End = Visuals.m_vTilesOfLayer[EndIndex]; - unsigned int NumVertices = ((End.IndexBufferByteOffset() - Start.IndexBufferByteOffset()) / sizeof(unsigned int)) + (End.DoDraw() ? 6lu : 0lu); + dbg_assert(End.IndexBufferByteOffset() >= Start.IndexBufferByteOffset(), "Tile offsets are not monotone."); + unsigned int NumVertices = ((End.IndexBufferByteOffset() - Start.IndexBufferByteOffset()) / sizeof(unsigned int)) + End.QuadCount() * 6u; if(NumVertices) { @@ -386,7 +394,7 @@ void CRenderLayerTile::RenderTileLayer(const ColorRGBA &Color, const CRenderLaye const auto &Start = Visuals.m_vTilesOfLayer[StartIndex]; const auto &End = Visuals.m_vTilesOfLayer[EndIndex]; dbg_assert(End.IndexBufferByteOffset() >= Start.IndexBufferByteOffset(), "Tile offsets are not monotone."); - unsigned int NumVertices = ((End.IndexBufferByteOffset() - Start.IndexBufferByteOffset()) / sizeof(unsigned int)) + (End.DoDraw() ? 6lu : 0lu); + unsigned int NumVertices = ((End.IndexBufferByteOffset() - Start.IndexBufferByteOffset()) / sizeof(unsigned int)) + End.QuadCount() * 6u; if(NumVertices) { @@ -463,7 +471,7 @@ void CRenderLayerTile::RenderTileBorder(const ColorRGBA &Color, int BorderX0, in // borders auto DrawBorder = [&](vec2 Offset, vec2 Scale, CTileLayerVisuals::CTileVisual &StartVisual, CTileLayerVisuals::CTileVisual &EndVisual) { - unsigned int DrawNum = ((EndVisual.IndexBufferByteOffset() - StartVisual.IndexBufferByteOffset()) / (sizeof(unsigned int) * 6)) + (EndVisual.DoDraw() ? 1lu : 0lu); + unsigned int DrawNum = ((EndVisual.IndexBufferByteOffset() - StartVisual.IndexBufferByteOffset()) / (sizeof(unsigned int) * 6)) + EndVisual.QuadCount(); offset_ptr_size pOffset = (offset_ptr_size)StartVisual.IndexBufferByteOffset(); Offset *= 32.0f; Graphics()->RenderBorderTiles(Visuals.m_BufferContainerIndex, Color, pOffset, Offset, Scale, DrawNum); @@ -581,17 +589,179 @@ ColorRGBA CRenderLayerTile::GetRenderColor(const CRenderLayerParams &Params) con return Color; } +void CRenderLayerTile::UpdateRounding() +{ + if(!m_pEntityRegions) + return; + const CScreenRect ScreenRect = Graphics()->GetScreen(); + const int Percent = g_Config.m_BcEntitiesRounding; + const int Mode = g_Config.m_BcEntitiesRoundingMode; + const int Steps = RoundedTiles::Detail(Percent * 0.16f, Graphics()->ScreenWidth() / std::max(1.0f, ScreenRect.Width())); + const bool GeometryChanged = Percent != m_RoundingPercent || Mode != m_RoundingMode; + if(!GeometryChanged && Steps == m_RoundingSteps) + return; + const bool WasRounded = m_RoundingPercent > 0; + m_RoundingPercent = Percent; + m_RoundingMode = Mode; + m_RoundingSteps = Steps; + m_RoundedShapes.clear(); + // The map-wide buffer uses a fixed sufficient detail for ordinary zoom. + // Zoom-only LOD changes must not synchronously re-upload every map tile. + if(GeometryChanged && Graphics()->IsTileBufferingEnabled() && (WasRounded || Percent > 0)) + { + if(m_VisualTiles) + m_VisualTiles->Unload(); + // Runtime updates must not draw the loading menu or change its projection. + auto LoadingCallback = std::move(m_InitCallback); + m_InitCallback.reset(); + m_BuildingRoundingBuffer = true; + UploadTileData(m_VisualTiles, 0, false, m_RoundingGame); + ReuploadRoundedOverlays(); + m_BuildingRoundingBuffer = false; + m_InitCallback = std::move(LoadingCallback); + Graphics()->MapScreen(ScreenRect); + } +} + +const RoundedTiles::CShape *CRenderLayerTile::RoundedShape(int X, int Y, int Index) +{ + if(!m_pEntityRegions || m_RoundingPercent <= 0 || !Index) + return nullptr; + // A front-layer through tile and the game tile beneath it share a mesh. + if(!m_pEntityRegions->ShouldRoundTile(X, Y, m_RoundingLayer, Index)) + return nullptr; + const int Type = m_pEntityRegions->Get(X, Y); + if(!Type) + return nullptr; + const unsigned Mask = m_pEntityRegions->Mask(X, Y, Type); + const unsigned Blockers = m_pEntityRegions->BlockerMask(X, Y, Type); + const unsigned Transition = m_pEntityRegions->TransitionCorners(X, Y); + if(Mask == 255) + return nullptr; + const int Steps = m_BuildingRoundingBuffer ? BufferedRoundingSteps : m_RoundingSteps; + const uint64_t Key = uint64_t(Mask) | (uint64_t(Blockers) << 8) | (uint64_t(Steps) << 16) | (uint64_t(Transition) << 23); + auto It = m_RoundedShapes.find(Key); + if(It == m_RoundedShapes.end()) + It = m_RoundedShapes.emplace(Key, RoundedTiles::Build(Mask, m_RoundingPercent * 0.16f, m_RoundingMode, Steps, Blockers, Transition)).first; + return &It->second; +} + +float CRenderLayerTile::RoundedOverlayScale(int X, int Y) const +{ + if(!m_pEntityRegions || m_RoundingPercent <= 0 || m_RoundingMode == 1) + return 1.0f; + const int Type = m_pEntityRegions->Get(X, Y); + if(Type != CEntityRegions::TELE && Type != CEntityRegions::SWITCH) + return 1.0f; + // A text size chosen per tile makes a number in the middle of a teleporter + // region much larger than the same number on its rounded end. Use one size + // for the whole region so labels stay consistent as the contour bends. + return 1.0f - (1.0f - std::sqrt(0.5f)) * (m_RoundingPercent / 100.0f); +} + +void CRenderLayerTile::RenderRoundedTiles(const ColorRGBA &Color, const CRenderLayerParams &Params) +{ + const CScreenRect ScreenRect = Graphics()->GetScreen(); + float X0 = ScreenRect.m_TopLeft.x, Y0 = ScreenRect.m_TopLeft.y; + float X1 = ScreenRect.m_BottomRight.x, Y1 = ScreenRect.m_BottomRight.y; + const int W = m_pLayerTilemap->m_Width, H = m_pLayerTilemap->m_Height; + const bool Arrays = Graphics()->HasTextureArraysSupport(); + const float Pixels = 32.0f * Graphics()->ScreenWidth() / std::max(1.0f, X1 - X0); + const float Inset = std::clamp(1.25f / std::max(1.0f, Pixels), 0.0f, 0.49f); + const RoundedTiles::CShape Square = RoundedTiles::Build(255, 0, 0, 2); + if(Params.m_FpsFogEnabled && Params.m_FpsFogCullMapTiles && Params.m_FpsFogHalfW > 0.0f && Params.m_FpsFogHalfH > 0.0f) + { + const vec2 Center((X0 + X1) * 0.5f, (Y0 + Y1) * 0.5f); + const vec2 Half(Params.m_FpsFogHalfW, Params.m_FpsFogHalfH); + if(Half.x > 0 && Half.y > 0) + { + X0 = std::max(X0, Center.x - Half.x); + Y0 = std::max(Y0, Center.y - Half.y); + X1 = std::min(X1, Center.x + Half.x); + Y1 = std::min(Y1, Center.y + Half.y); + } + } + if(Arrays) + Graphics()->QuadsTex3DBegin(); + else + Graphics()->QuadsBegin(); + Graphics()->SetColor(Color); + for(int Y = (int)std::floor(Y0 / 32) - 1; Y <= (int)std::ceil(Y1 / 32); ++Y) + for(int X = (int)std::floor(X0 / 32) - 1; X <= (int)std::ceil(X1 / 32); ++X) + { + if(!Params.m_RenderTileBorder && (X < 0 || Y < 0 || X >= W || Y >= H)) + continue; + unsigned char Index = 0, Flags = 0; + int Angle = -1; + const int MX = std::clamp(X, 0, W - 1), MY = std::clamp(Y, 0, H - 1); + GetTileData(&Index, &Flags, &Angle, MX, MY, 0); + if(!Index) + continue; + const auto *pShape = X == MX && Y == MY ? RoundedShape(X, Y, Index) : nullptr; + if(!pShape) + pShape = □ + const bool ThroughArtwork = m_RoundingLayer == 1 && CEntityRegions::IsThrough(Index); + const unsigned TableFlag = (Flags & (TILEFLAG_XFLIP | TILEFLAG_YFLIP)) + ((Flags & TILEFLAG_ROTATE) >> 1); + const auto &T = TEX_COORDS_TABLE[TableFlag]; + for(const auto &Q : pShape->m_vQuads) + { + vec2 UV[4]; + for(int K = 0; K < 4; ++K) + { + // Array/volume samplers clamp extrapolated corner UVs per fragment. + // The atlas fallback cannot do that without sampling adjacent tiles. + const vec2 SourceUv = RoundedTiles::SampleUv(Q, K, ThroughArtwork); + const vec2 SampleUv = Arrays ? SourceUv : vec2(std::clamp(SourceUv.x, 0.0f, 1.0f), std::clamp(SourceUv.y, 0.0f, 1.0f)); + UV[K] = RoundedTiles::Bilinear({vec2(T.m_aTexX[0], T.m_aTexY[0]), vec2(T.m_aTexX[1], T.m_aTexY[1]), vec2(T.m_aTexX[2], T.m_aTexY[2]), vec2(T.m_aTexX[3], T.m_aTexY[3])}, SampleUv.x, SampleUv.y); + if(!Arrays) + UV[K] = (vec2(Index % 16, Index / 16) + vec2(Inset, Inset) + UV[K] * (1 - 2 * Inset)) / 16.0f; + } + Graphics()->QuadsSetSubsetFree(UV[0].x, UV[0].y, UV[1].x, UV[1].y, UV[3].x, UV[3].y, UV[2].x, UV[2].y, Arrays ? Index : -1); + const vec2 Offset(X * 32.0f, Y * 32.0f); + const IGraphics::CFreeformItem Item(Q.m_Pos[0] + Offset, Q.m_Pos[1] + Offset, Q.m_Pos[3] + Offset, Q.m_Pos[2] + Offset); + if(Arrays) + Graphics()->QuadsTex3DDrawFreeform(&Item, 1); + else + Graphics()->QuadsDrawFreeform(&Item, 1); + } + } + if(Arrays) + Graphics()->QuadsTex3DEnd(); + else + Graphics()->QuadsEnd(); +} + void CRenderLayerTile::Render(const CRenderLayerParams &Params) { + UpdateRounding(); UseTexture(GetTexture()); ColorRGBA Color = GetRenderColor(Params); - if(Graphics()->IsTileBufferingEnabled() && Params.m_TileAndQuadBuffering) + const bool Buffered = Graphics()->IsTileBufferingEnabled() && Params.m_TileAndQuadBuffering; + const bool DynamicRounded = m_pEntityRegions && m_RoundingPercent > 0 && (!Buffered || m_RoundingSteps > BufferedRoundingSteps); + if(Buffered && !DynamicRounded) { RenderTileLayerWithTileBuffer(Color, Params); } else { - RenderTileLayerNoTileBuffer(Color, Params); + if(DynamicRounded) + { + Graphics()->BlendNormal(); + if(m_RoundingGame && Params.m_RenderTileBorder) + RenderMap()->RenderTileRectangle(-BorderRenderDistance, -BorderRenderDistance, m_pLayerTilemap->m_Width + 2 * BorderRenderDistance, m_pLayerTilemap->m_Height + 2 * BorderRenderDistance, + TILE_AIR, TILE_DEATH, 32.0f, Color.Multiply(static_cast(this)->GetDeathBorderColor()), TILERENDERFLAG_EXTEND | LAYERRENDERFLAG_TRANSPARENT); + RenderRoundedTiles(Color, Params); + if(m_RoundingTele) + RenderMap()->RenderTeleOverlay(GetData(), m_pLayerTilemap->m_Width, m_pLayerTilemap->m_Height, 32.0f, + (Params.m_RenderText ? OVERLAYRENDERFLAG_TEXT : 0) | (Params.m_RenderInvalidTiles ? OVERLAYRENDERFLAG_EDITOR : 0), Color.a, + [this](int X, int Y) { return RoundedOverlayScale(X, Y); }); + else if(m_RoundingSwitch) + RenderMap()->RenderSwitchOverlay(GetData(), m_pLayerTilemap->m_Width, m_pLayerTilemap->m_Height, 32.0f, + (Params.m_RenderText ? OVERLAYRENDERFLAG_TEXT : 0) | (Params.m_RenderInvalidTiles ? OVERLAYRENDERFLAG_EDITOR : 0), Color.a, + [this](int X, int Y) { return RoundedOverlayScale(X, Y); }); + } + else + RenderTileLayerNoTileBuffer(Color, Params); } if(Params.m_DebugRenderTileClips && m_LayerClip.has_value()) @@ -713,9 +883,48 @@ void CRenderLayerTile::UploadTileData(std::optional &VisualsO int TilesHandledCount = vTmpTiles.size(); Visuals.m_vTilesOfLayer[y * m_pLayerTilemap->m_Width + x].SetIndexBufferByteOffset((offset_ptr32)(TilesHandledCount)); - if(AddTile(vTmpTiles, vTmpTileTexCoords, Index, Flags, x, y, DoTextureCoords, AddAsSpeedup, AngleRotate)) + const auto *pShape = CurOverlay == 0 ? RoundedShape(x, y, Index) : nullptr; + if(pShape) { - Visuals.m_vTilesOfLayer[y * m_pLayerTilemap->m_Width + x].Draw(true); + const bool ThroughArtwork = m_RoundingLayer == 1 && CEntityRegions::IsThrough(Index); + CGraphicTile Base; + CGraphicTileTextureCoords Tex; + FillTmpTile(&Base, &Tex, Flags, Index, x, y, ivec2(0, 0), 32); + const vec4 T[] = {Tex.m_TexCoordTopLeft, Tex.m_TexCoordTopRight, Tex.m_TexCoordBottomRight, Tex.m_TexCoordBottomLeft}; + for(const auto &Q : pShape->m_vQuads) + { + const vec2 Offset(x * 32.0f, y * 32.0f); + vTmpTiles.push_back({Q.m_Pos[0] + Offset, Q.m_Pos[1] + Offset, Q.m_Pos[2] + Offset, Q.m_Pos[3] + Offset}); + if(DoTextureCoords) + { + vec4 U[4]; + for(int K = 0; K < 4; ++K) + { + const vec2 SourceUv = RoundedTiles::SampleUv(Q, K, ThroughArtwork); + U[K] = mix(mix(T[0], T[1], SourceUv.x), mix(T[3], T[2], SourceUv.x), SourceUv.y); + } + vTmpTileTexCoords.push_back({U[0], U[1], U[2], U[3]}); + } + } + } + else + AddTile(vTmpTiles, vTmpTileTexCoords, Index, Flags, x, y, DoTextureCoords, AddAsSpeedup, AngleRotate); + if(CurOverlay != 0 && vTmpTiles.size() > (size_t)TilesHandledCount) + { + const float OverlayScale = RoundedOverlayScale(x, y); + if(OverlayScale < 1.0f) + { + auto &Tile = vTmpTiles.back(); + const vec2 Center(x * 32.0f + 16.0f, y * 32.0f + 16.0f); + Tile.m_TopLeft = Center + (Tile.m_TopLeft - Center) * OverlayScale; + Tile.m_TopRight = Center + (Tile.m_TopRight - Center) * OverlayScale; + Tile.m_BottomRight = Center + (Tile.m_BottomRight - Center) * OverlayScale; + Tile.m_BottomLeft = Center + (Tile.m_BottomLeft - Center) * OverlayScale; + } + } + if(vTmpTiles.size() > (size_t)TilesHandledCount) + { + Visuals.m_vTilesOfLayer[y * m_pLayerTilemap->m_Width + x].SetQuadCount(vTmpTiles.size() - TilesHandledCount); // calculate clip region boundaries based on draws DrawLeft = std::min(DrawLeft, x); @@ -793,6 +1002,13 @@ void CRenderLayerTile::UploadTileData(std::optional &VisualsO m_LayerClip->m_Y = DrawTop * 32.0f; m_LayerClip->m_Width = (DrawRight - DrawLeft + 1) * 32.0f; m_LayerClip->m_Height = (DrawBottom - DrawTop + 1) * 32.0f; + if(m_pEntityRegions && m_RoundingPercent > 0) + { + m_LayerClip->m_X -= 16; + m_LayerClip->m_Y -= 16; + m_LayerClip->m_Width += 32; + m_LayerClip->m_Height += 32; + } } } @@ -867,10 +1083,10 @@ void CRenderLayerTile::UploadTileData(std::optional &VisualsO { public: vec2 m_Pos; - ubvec4 m_Tex; + vec4 m_Tex; }; - static_assert(sizeof(CVertex) == sizeof(vec2) + sizeof(ubvec4)); // no padding + static_assert(sizeof(CVertex) == sizeof(vec2) + sizeof(vec4)); // no padding CVertex *pDst = static_cast(pUploadData); dbg_assert(UploadDataSize == vTmpTiles.size() * sizeof(*pDst) * 4, "invalid upload size"); @@ -898,7 +1114,7 @@ void CRenderLayerTile::UploadTileData(std::optional &VisualsO // then create the buffer container SBufferContainerInfo ContainerInfo; - ContainerInfo.m_Stride = (DoTextureCoords ? (sizeof(float) * 2 + sizeof(ubvec4)) : 0); + ContainerInfo.m_Stride = (DoTextureCoords ? (sizeof(vec2) + sizeof(vec4)) : 0); ContainerInfo.m_VertBufferBindingIndex = BufferObjectIndex; ContainerInfo.m_vAttributes.emplace_back(); SBufferContainerInfo::SAttribute *pAttr = &ContainerInfo.m_vAttributes.back(); @@ -912,10 +1128,10 @@ void CRenderLayerTile::UploadTileData(std::optional &VisualsO ContainerInfo.m_vAttributes.emplace_back(); pAttr = &ContainerInfo.m_vAttributes.back(); pAttr->m_DataTypeCount = 4; - pAttr->m_Type = GRAPHICS_TYPE_UNSIGNED_BYTE; + pAttr->m_Type = GRAPHICS_TYPE_FLOAT; pAttr->m_Normalized = false; pAttr->m_pOffset = (void *)(sizeof(vec2)); - pAttr->m_FuncType = 1; + pAttr->m_FuncType = 0; } Visuals.m_BufferContainerIndex = Graphics()->CreateBufferContainer(&ContainerInfo); @@ -1587,6 +1803,13 @@ void CRenderLayerEntityTele::Init() UploadTileData(m_VisualTeleNumbers, 1, false); } +void CRenderLayerEntityTele::ReuploadRoundedOverlays() +{ + if(m_VisualTeleNumbers) + m_VisualTeleNumbers->Unload(); + UploadTileData(m_VisualTeleNumbers, 1, false); +} + void CRenderLayerEntityTele::InitTileData() { m_pTeleTiles = GetData(); @@ -1737,6 +1960,16 @@ void CRenderLayerEntitySwitch::Init() UploadTileData(m_VisualSwitchNumberBottom, 2, false); } +void CRenderLayerEntitySwitch::ReuploadRoundedOverlays() +{ + if(m_VisualSwitchNumberTop) + m_VisualSwitchNumberTop->Unload(); + if(m_VisualSwitchNumberBottom) + m_VisualSwitchNumberBottom->Unload(); + UploadTileData(m_VisualSwitchNumberTop, 1, false); + UploadTileData(m_VisualSwitchNumberBottom, 2, false); +} + void CRenderLayerEntitySwitch::InitTileData() { m_pSwitchTiles = GetData(); diff --git a/src/game/map/render_layer.h b/src/game/map/render_layer.h index 9b280f35c1..d0b8d2157a 100644 --- a/src/game/map/render_layer.h +++ b/src/game/map/render_layer.h @@ -11,14 +11,17 @@ using offset_ptr32 = unsigned int; #include +#include #include #include #include +#include #include #include #include #include +#include #include class CMapLayers; @@ -127,6 +130,16 @@ class CRenderLayerTile : public CRenderLayer { public: CRenderLayerTile(int GroupId, int LayerId, int Flags, CMapItemLayerTilemap *pLayerTilemap); + void SetEntityRegions(std::shared_ptr pRegions, bool Tele, bool Game, bool Switch) + { + m_pEntityRegions = std::move(pRegions); + m_RoundingTele = Tele; + m_RoundingGame = Game; + m_RoundingSwitch = Switch; + m_RoundingLayer = Switch ? 3 : Tele ? 2 : + Game ? 0 : + 1; + } ~CRenderLayerTile() override = default; void Render(const CRenderLayerParams &Params) override; bool DoRender(const CRenderLayerParams &Params) override; @@ -145,8 +158,20 @@ class CRenderLayerTile : public CRenderLayer virtual ColorRGBA GetRenderColor(const CRenderLayerParams &Params) const; virtual void InitTileData(); virtual void GetTileData(unsigned char *pIndex, unsigned char *pFlags, int *pAngleRotate, unsigned int x, unsigned int y, int CurOverlay) const; + virtual void ReuploadRoundedOverlays() {} IGraphics::CTextureHandle GetTexture() const override { return m_TextureHandle; } CTile *m_pTiles; + std::shared_ptr m_pEntityRegions; + bool m_RoundingTele = false, m_RoundingGame = false, m_RoundingSwitch = false; + int m_RoundingLayer = 0; + int m_RoundingPercent = -1, m_RoundingMode = -1, m_RoundingSteps = 2; + bool m_BuildingRoundingBuffer = false; + static constexpr int BufferedRoundingSteps = 4; + std::unordered_map m_RoundedShapes; + void UpdateRounding(); + const RoundedTiles::CShape *RoundedShape(int X, int Y, int Index); + float RoundedOverlayScale(int X, int Y) const; + void RenderRoundedTiles(const ColorRGBA &Color, const CRenderLayerParams &Params); private: IGraphics::CTextureHandle m_TextureHandle; @@ -169,36 +194,38 @@ class CRenderLayerTile : public CRenderLayer class CTileVisual { public: - CTileVisual() : - m_IndexBufferByteOffset(0) {} + CTileVisual() = default; private: - offset_ptr32 m_IndexBufferByteOffset; + offset_ptr32 m_IndexBufferByteOffset = 0; + unsigned int m_QuadCount = 0; public: bool DoDraw() const { - return (m_IndexBufferByteOffset & 0x10000000) != 0; + return m_QuadCount != 0; } void Draw(bool SetDraw) { - m_IndexBufferByteOffset = (SetDraw ? 0x10000000 : (offset_ptr32)0) | (m_IndexBufferByteOffset & 0xEFFFFFFF); + m_QuadCount = SetDraw ? 1 : 0; } + void SetQuadCount(unsigned int Count) { m_QuadCount = Count; } + unsigned int QuadCount() const { return m_QuadCount; } offset_ptr IndexBufferByteOffset() const { - return ((offset_ptr)(m_IndexBufferByteOffset & 0xEFFFFFFF) * 6 * sizeof(uint32_t)); + return (offset_ptr)m_IndexBufferByteOffset * 6 * sizeof(uint32_t); } void SetIndexBufferByteOffset(offset_ptr32 IndexBufferByteOff) { - m_IndexBufferByteOffset = IndexBufferByteOff | (m_IndexBufferByteOffset & 0x10000000); + m_IndexBufferByteOffset = IndexBufferByteOff; } void AddIndexBufferByteOffset(offset_ptr32 IndexBufferByteOff) { - m_IndexBufferByteOffset = ((m_IndexBufferByteOffset & 0xEFFFFFFF) + IndexBufferByteOff) | (m_IndexBufferByteOffset & 0x10000000); + m_IndexBufferByteOffset += IndexBufferByteOff; } }; @@ -313,7 +340,7 @@ class CRenderLayerEntityGame final : public CRenderLayerEntityBase void RenderTileLayerWithTileBuffer(const ColorRGBA &Color, const CRenderLayerParams &Params) override; void RenderTileLayerNoTileBuffer(const ColorRGBA &Color, const CRenderLayerParams &Params) override; -private: +public: ColorRGBA GetDeathBorderColor() const; }; @@ -337,6 +364,7 @@ class CRenderLayerEntityTele final : public CRenderLayerEntityBase void RenderTileLayerWithTileBuffer(const ColorRGBA &Color, const CRenderLayerParams &Params) override; void RenderTileLayerNoTileBuffer(const ColorRGBA &Color, const CRenderLayerParams &Params) override; void GetTileData(unsigned char *pIndex, unsigned char *pFlags, int *pAngleRotate, unsigned int x, unsigned int y, int CurOverlay) const override; + void ReuploadRoundedOverlays() override; private: std::optional m_VisualTeleNumbers; @@ -378,6 +406,7 @@ class CRenderLayerEntitySwitch final : public CRenderLayerEntityBase void RenderTileLayerNoTileBuffer(const ColorRGBA &Color, const CRenderLayerParams &Params) override; void GetTileData(unsigned char *pIndex, unsigned char *pFlags, int *pAngleRotate, unsigned int x, unsigned int y, int CurOverlay) const override; IGraphics::CTextureHandle GetTexture() const override; + void ReuploadRoundedOverlays() override; private: std::optional m_VisualSwitchNumberTop; diff --git a/src/game/map/render_map.cpp b/src/game/map/render_map.cpp index 4f021388c0..29bd78be0c 100644 --- a/src/game/map/render_map.cpp +++ b/src/game/map/render_map.cpp @@ -749,7 +749,7 @@ void CRenderMap::RenderTilemap(CTile *pTiles, int w, int h, float Scale, ColorRG Graphics()->MapScreen(ScreenRect); } -void CRenderMap::RenderTeleOverlay(CTeleTile *pTele, int w, int h, float Scale, int OverlayRenderFlag, float Alpha) +void CRenderMap::RenderTeleOverlay(CTeleTile *pTele, int w, int h, float Scale, int OverlayRenderFlag, float Alpha, const std::function &ScaleAt) { if(!(OverlayRenderFlag & OVERLAYRENDERFLAG_TEXT)) return; @@ -785,9 +785,10 @@ void CRenderMap::RenderTeleOverlay(CTeleTile *pTele, int w, int h, float Scale, // Auto-resize text to fit inside the tile float ScaledWidth = TextRender()->TextWidth(Size * Scale, aBuf, -1); float Factor = std::clamp(Scale / ScaledWidth, 0.0f, 1.0f); - float LocalSize = Size * Factor; + float LocalSize = Size * Factor * (ScaleAt ? std::clamp(ScaleAt(x, y), 0.0f, 1.0f) : 1.0f); float ToCenterOffset = (1 - LocalSize) / 2.f; - TextRender()->Text((x + 0.5f) * Scale - (ScaledWidth * Factor) / 2.0f, (y + ToCenterOffset) * Scale, LocalSize * Scale, aBuf); + float LocalWidth = TextRender()->TextWidth(LocalSize * Scale, aBuf, -1); + TextRender()->Text((x + 0.5f) * Scale - LocalWidth / 2.0f, (y + ToCenterOffset) * Scale, LocalSize * Scale, aBuf); } } } @@ -873,7 +874,7 @@ void CRenderMap::RenderSpeedupOverlay(CSpeedupTile *pSpeedup, int w, int h, floa Graphics()->MapScreen(ScreenRect); } -void CRenderMap::RenderSwitchOverlay(CSwitchTile *pSwitch, int w, int h, float Scale, int OverlayRenderFlag, float Alpha) +void CRenderMap::RenderSwitchOverlay(CSwitchTile *pSwitch, int w, int h, float Scale, int OverlayRenderFlag, float Alpha, const std::function &ScaleAt) { if(!(OverlayRenderFlag & OVERLAYRENDERFLAG_TEXT)) return; @@ -893,7 +894,6 @@ void CRenderMap::RenderSwitchOverlay(CSwitchTile *pSwitch, int w, int h, float S EndX = std::min(w, EndX); float Size = g_Config.m_ClTextEntitiesSize / 100.f; - float ToCenterOffset = (1 - Size) / 2.f; char aBuf[16]; TextRender()->TextColor(1.0f, 1.0f, 1.0f, Alpha); @@ -902,19 +902,26 @@ void CRenderMap::RenderSwitchOverlay(CSwitchTile *pSwitch, int w, int h, float S for(int x = StartX; x < EndX; x++) { int c = x + y * w; + const float SafeScale = ScaleAt ? std::clamp(ScaleAt(x, y), 0.0f, 1.0f) : 1.0f; unsigned char Index = pSwitch[c].m_Number; if(Index && IsSwitchTileNumberUsed(pSwitch[c].m_Type)) { str_format(aBuf, sizeof(aBuf), "%d", Index); - TextRender()->Text(x * Scale, (y + ToCenterOffset / 2) * Scale, Size * Scale / 2.f, aBuf); + const float FullWidth = TextRender()->TextWidth(Size * Scale / 2.0f, aBuf, -1); + const float LocalSize = Size * SafeScale * std::clamp(Scale / std::max(FullWidth, 1.0f), 0.0f, 1.0f); + const float Width = TextRender()->TextWidth(LocalSize * Scale / 2.0f, aBuf, -1); + TextRender()->Text((x + 0.5f) * Scale - Width / 2.0f, (y + (1 - LocalSize) / 4.0f) * Scale, LocalSize * Scale / 2.f, aBuf); } unsigned char Delay = pSwitch[c].m_Delay; if(Delay && IsSwitchTileDelayUsed(pSwitch[c].m_Type)) { str_format(aBuf, sizeof(aBuf), "%d", Delay); - TextRender()->Text(x * Scale, (y + 0.5f + ToCenterOffset / 2) * Scale, Size * Scale / 2.f, aBuf); + const float FullWidth = TextRender()->TextWidth(Size * Scale / 2.0f, aBuf, -1); + const float LocalSize = Size * SafeScale * std::clamp(Scale / std::max(FullWidth, 1.0f), 0.0f, 1.0f); + const float Width = TextRender()->TextWidth(LocalSize * Scale / 2.0f, aBuf, -1); + TextRender()->Text((x + 0.5f) * Scale - Width / 2.0f, (y + 0.5f + (1 - LocalSize) / 4.0f) * Scale, LocalSize * Scale / 2.f, aBuf); } } } diff --git a/src/game/map/render_map.h b/src/game/map/render_map.h index 305dfafa92..e774a17d84 100644 --- a/src/game/map/render_map.h +++ b/src/game/map/render_map.h @@ -8,6 +8,7 @@ #include #include +#include #include enum @@ -87,9 +88,9 @@ class CRenderMap void RenderTileRectangle(int RectX, int RectY, int RectW, int RectH, unsigned char IndexIn, unsigned char IndexOut, float Scale, ColorRGBA Color, int RenderFlags); // DDRace - void RenderTeleOverlay(CTeleTile *pTele, int w, int h, float Scale, int OverlayRenderFlags, float Alpha = 1.0f); + void RenderTeleOverlay(CTeleTile *pTele, int w, int h, float Scale, int OverlayRenderFlags, float Alpha = 1.0f, const std::function &ScaleAt = {}); void RenderSpeedupOverlay(CSpeedupTile *pSpeedup, int w, int h, float Scale, int OverlayRenderFlags, float Alpha = 1.0f); - void RenderSwitchOverlay(CSwitchTile *pSwitch, int w, int h, float Scale, int OverlayRenderFlags, float Alpha = 1.0f); + void RenderSwitchOverlay(CSwitchTile *pSwitch, int w, int h, float Scale, int OverlayRenderFlags, float Alpha = 1.0f, const std::function &ScaleAt = {}); void RenderTuneOverlay(CTuneTile *pTune, int w, int h, float Scale, int OverlayRenderFlags, float Alpha = 1.0f); void RenderTelemap(CTeleTile *pTele, int w, int h, float Scale, ColorRGBA Color, int RenderFlags); void RenderSwitchmap(CSwitchTile *pSwitch, int w, int h, float Scale, ColorRGBA Color, int RenderFlags); diff --git a/src/game/map/rounded_tiles.h b/src/game/map/rounded_tiles.h new file mode 100644 index 0000000000..4f7f1c9a72 --- /dev/null +++ b/src/game/map/rounded_tiles.h @@ -0,0 +1,417 @@ +#ifndef GAME_MAP_ROUNDED_TILES_H +#define GAME_MAP_ROUNDED_TILES_H + +#include + +#include +#include +#include +#include + +// Rendering only. Map coordinates, entity IDs and collision stay integral. +namespace RoundedTiles +{ + struct CQuad + { + std::array m_Pos; // clockwise: TL, TR, BR, BL + std::array m_Uv; // original cell parameter, including shared edges + std::array m_SampleUv; // texture sampling can hold artwork in place + vec2 m_RepeatOffset = vec2(0, 0); // diagonal cell filled by an inner wedge + }; + + inline vec2 SampleUv(const CQuad &Quad, int Vertex, bool RepeatingArtwork) + { + // A complementary wedge lies wholly in the diagonal cell. A repeating + // through-hook pattern uses that cell's local UVs instead of clamping + // the source tile's edge across the newly filled area. + return Quad.m_SampleUv[Vertex] - (RepeatingArtwork ? Quad.m_RepeatOffset : vec2(0, 0)); + } + + struct CSegment + { + vec2 m_A, m_B; + int m_SharedCorner = -1; + bool m_ButtA = false, m_ButtB = false; + }; + + struct CShape + { + std::vector m_vQuads; + std::vector m_vContour; + std::vector m_vOutlineJoins; + }; + + // Bits in row order, omitting the center: NW N NE W E SW S SE. + inline bool Occupied(unsigned Mask, int X, int Y) + { + if(X == 0 && Y == 0) + return true; + const int Index = (Y + 1) * 3 + X + 1; + return (Mask & (1u << (Index > 4 ? Index - 1 : Index))) != 0; + } + + inline int Detail(float Radius, float PixelsPerUnit) + { + // Power-of-two half-arc segments keep neighboring layers at the same LOD. + int Steps = 2; + while(Steps < 64 && Radius * PixelsPerUnit * (1.0f - std::cos(pi / (8.0f * Steps))) > 0.25f) + Steps *= 2; + return Steps; + } + + inline vec2 Bilinear(const std::array &P, float U, float V) + { + return mix(mix(P[0], P[1], U), mix(P[3], P[2], U), V); + } + + struct CCorner + { + vec2 m_Origin, m_Inward, m_Center, m_Node; + bool m_Active = false; + bool m_ArcX = false, m_ArcY = false; + bool m_Inner = false; + bool m_Complement = false; + bool m_TrimX = false, m_TrimY = false; + float m_Radius = 0.0f; + + vec2 Tangent(bool AlongX) const + { + return m_Origin + (AlongX ? vec2(m_Inward.x * m_Radius, 0) : vec2(0, m_Inward.y * m_Radius)); + } + + vec2 Edge(bool AlongX, float T) const + { + const vec2 End = Tangent(AlongX); + if(!(AlongX ? m_ArcX : m_ArcY)) + return mix(m_Node, End, T); + const vec2 A = (m_Node - m_Center) / m_Radius; + const vec2 B = (End - m_Center) / m_Radius; + const float Angle = std::atan2(A.x * B.y - A.y * B.x, dot(A, B)) * T; + const float C = std::cos(Angle), S = std::sin(Angle); + return m_Center + vec2(A.x * C - A.y * S, A.x * S + A.y * C) * m_Radius; + } + + vec2 Map(float U, float V) const + { + const vec2 X = Tangent(true), Y = Tangent(false); + const vec2 Far = m_Origin + m_Inward * m_Radius; + // Coons patch: two curved/shifted edges and two unchanged outer edges. + return Edge(true, U) * (1 - V) + mix(Y, Far, U) * V + + Edge(false, V) * (1 - U) + mix(X, Far, V) * U - + Bilinear({m_Node, X, Far, Y}, U, V); + } + }; + + inline CCorner Corner(unsigned Mask, int X, int Y, float Radius, int Mode, unsigned BlockerMask = 0) + { + CCorner C; + C.m_Origin = vec2(X > 0 ? 32 : 0, Y > 0 ? 32 : 0); + C.m_Inward = vec2(-X, -Y); + C.m_Node = C.m_Origin; + C.m_Radius = Radius; + const bool H = Occupied(Mask, X, 0), V = Occupied(Mask, 0, Y), D = Occupied(Mask, X, Y); + const int Count = 1 + H + V + D; + // Diagonally touching regions keep separate convex corners. + if(Radius > 0 && !H && !V && !Occupied(BlockerMask, X, 0) && !Occupied(BlockerMask, 0, Y) && Mode != 1) + { + C.m_Active = C.m_ArcX = C.m_ArcY = true; + C.m_Center = C.m_Origin + C.m_Inward * Radius; + C.m_Node = C.m_Origin + C.m_Inward * (Radius * (1 - std::sqrt(0.5f))); + } + else if(Radius > 0 && Count == 3 && Mode != 0 && + !Occupied(BlockerMask, !H ? X : (!V ? 0 : X), !H ? 0 : Y)) + { + C.m_Active = C.m_Inner = true; + const vec2 Missing(!H ? X : (!V ? -X : X), !H ? -Y : Y); + C.m_Center = C.m_Origin + Missing * Radius; + C.m_Node = C.m_Origin + Missing * (Radius * (1 - std::sqrt(0.5f))); + C.m_ArcX = !V; + C.m_ArcY = !H; + } + return C; + } + + inline CShape Build(unsigned Mask, float Radius, int Mode, int Steps, unsigned BlockerMask = 0, unsigned TransitionCorners = 0) + { + CShape Shape; + Radius = std::clamp(Radius, 0.0f, 16.0f); + Steps = std::clamp(Steps, 2, 64); + const unsigned ComplementCorners = TransitionCorners & 15u; + const unsigned TrimCorners = (TransitionCorners >> 4) & 15u; + const auto MakeCorner = [&](int I, int X, int Y) { + // A covered material corner must round even if its neighbor would + // normally block it. An empty corner still follows the selected mode. + const bool Covered = ((ComplementCorners & (1u << I)) && Occupied(BlockerMask, X, Y)) || + ((TrimCorners & (1u << I)) && (Occupied(BlockerMask, X, 0) || Occupied(BlockerMask, 0, Y))); + return Corner(Mask, X, Y, Radius, Covered ? 2 : Mode, Covered ? 0 : BlockerMask); + }; + std::array Corners = { + MakeCorner(0, -1, -1), MakeCorner(1, 1, -1), MakeCorner(2, 1, 1), MakeCorner(3, -1, 1)}; + for(int I = 0; I < 4; ++I) + { + Corners[I].m_Complement = (ComplementCorners & (1u << I)) && Corners[I].m_Active; + const int X = I == 1 || I == 2 ? 1 : -1; + const int Y = I >= 2 ? 1 : -1; + if(TrimCorners & (1u << I)) + { + const bool MissingY = !Occupied(Mask, 0, Y); + const bool MissingX = !Occupied(Mask, X, 0); + // An open-sky concave corner only has an arc in inner/all mode. + // A covered material junction has a complementary arc even in + // outer-only mode, so its two side tiles still need trimming. + if(Mode != 0 || (MissingY && Occupied(BlockerMask, 0, Y)) || (MissingX && Occupied(BlockerMask, X, 0))) + { + Corners[I].m_TrimX = MissingY; + Corners[I].m_TrimY = MissingX; + // Keep the arc as a distance source: its inward band reaches + // into this side tile before the straight-edge tangent. Only + // the diagonal tile adds geometry into the empty cell. + } + } + } + // A covered concave corner keeps its original square and draws the + // complementary wedge into the other tile. Moving its corner vertex + // like an open-sky junction would leave a transparent sliver between + // the two materials. + auto MeshCorners = Corners; + for(auto &C : MeshCorners) + if((C.m_Complement || C.m_TrimX || C.m_TrimY) && C.m_Inner) + C.m_Active = false; + bool Active = false; + for(const auto &C : MeshCorners) + Active |= C.m_Active; + if(!Active) + Shape.m_vQuads.push_back({{vec2(0, 0), vec2(32, 0), vec2(32, 32), vec2(0, 32)}, {vec2(0, 0), vec2(1, 0), vec2(1, 1), vec2(0, 1)}, {vec2(0, 0), vec2(1, 0), vec2(1, 1), vec2(0, 1)}}); + else + { + const float Grid[] = {0, Radius, 32 - Radius, 32}; + for(int Y = 0; Y < 3; ++Y) + for(int X = 0; X < 3; ++X) + { + if(Grid[X] == Grid[X + 1] || Grid[Y] == Grid[Y + 1]) + continue; + const int CornerIndex = Y == 0 ? (X == 0 ? 0 : 1) : (X == 0 ? 3 : 2); + const CCorner *pC = X != 1 && Y != 1 ? &MeshCorners[CornerIndex] : nullptr; + const int N = pC && pC->m_Active ? Steps : 1; + for(int J = 0; J < N; ++J) + for(int I = 0; I < N; ++I) + { + CQuad Q; + for(int K = 0; K < 4; ++K) + { + const float U = (I + (K == 1 || K == 2)) / float(N); + const float V = (J + (K >= 2)) / float(N); + vec2 P(mix(Grid[X], Grid[X + 1], U), mix(Grid[Y], Grid[Y + 1], V)); + Q.m_Pos[K] = pC && pC->m_Active ? pC->Map(X == 0 ? U : 1 - U, Y == 0 ? V : 1 - V) : P; + Q.m_Uv[K] = P / 32.0f; + // Let the texture sampler clamp the extrapolated UV per fragment. + // Clamping only mesh vertices interpolates across the cell edge, + // smearing the artwork as the mesh LOD changes with zoom. + Q.m_SampleUv[K] = pC && pC->m_Inner ? Q.m_Pos[K] / 32.0f : Q.m_Uv[K]; + } + Shape.m_vQuads.push_back(Q); + } + } + } + for(const auto &C : Corners) + { + if(!C.m_Complement || !C.m_Inner) + continue; + const vec2 Sign = (C.m_Center - C.m_Origin) / Radius; + vec2 Previous = C.m_Center - vec2(Sign.x * Radius, 0); + for(int I = 1; I <= 2 * Steps; ++I) + { + const float A = (pi * 0.5f) * I / (2 * Steps); + const vec2 Next = C.m_Center - vec2(Sign.x * std::cos(A), Sign.y * std::sin(A)) * Radius; + vec2 P = Previous, Q = Next; + if((P.x - C.m_Origin.x) * (Q.y - C.m_Origin.y) - (P.y - C.m_Origin.y) * (Q.x - C.m_Origin.x) < 0) + std::swap(P, Q); + CQuad Wedge; + Wedge.m_Pos = {C.m_Origin, P, Q, Q}; + Wedge.m_RepeatOffset = Sign; + for(int K = 0; K < 4; ++K) + Wedge.m_Uv[K] = Wedge.m_SampleUv[K] = Wedge.m_Pos[K] / 32.0f; + Shape.m_vQuads.push_back(Wedge); + Previous = Next; + } + } + + // Full arcs are included even for the diagonally opposite junction tile: + // a wide inward outline can reach that tile without crossing one of its edges. + for(int CornerIndex = 0; CornerIndex < 4; ++CornerIndex) + { + const auto &C = Corners[CornerIndex]; + if(!C.m_Active) + { + const int X = C.m_Inward.x < 0 ? 1 : -1; + const int Y = C.m_Inward.y < 0 ? 1 : -1; + // Three tiles meet at this point, but the inner arc is disabled: + // either outer-only mode or a different region in the diagonal cell. + // The two straight contour bands then terminate in different tiles + // and touch only diagonally in the raster. Add their common endpoint + // to this tile's inward outline band to close that small gap. + if(Occupied(Mask, X, 0) && Occupied(Mask, 0, Y) && !Occupied(Mask, X, Y)) + Shape.m_vOutlineJoins.push_back(C.m_Origin); + continue; + } + const vec2 Sign = (C.m_Center - C.m_Origin) / Radius; + vec2 Previous = C.m_Center - vec2(Sign.x * Radius, 0); + for(int I = 1; I <= 2 * Steps; ++I) + { + const float A = (pi * 0.5f) * I / (2 * Steps); + vec2 Next = C.m_Center - vec2(Sign.x * std::cos(A), Sign.y * std::sin(A)) * Radius; + Shape.m_vContour.push_back({Previous, Next, + (C.m_Complement || C.m_TrimX || C.m_TrimY) ? CornerIndex : -1}); + Previous = Next; + } + } + const int DX[] = {0, 1, 0, -1}, DY[] = {-1, 0, 1, 0}; + for(int Side = 0; Side < 4; ++Side) + { + if(Occupied(Mask, DX[Side], DY[Side])) + continue; + const auto &A = Corners[Side], &B = Corners[(Side + 1) % 4]; + const bool AlongX = Side % 2 == 0; + const vec2 Start = A.m_Active || (AlongX ? A.m_TrimX : A.m_TrimY) ? A.Tangent(AlongX) : A.m_Origin; + const vec2 End = B.m_Active || (AlongX ? B.m_TrimX : B.m_TrimY) ? B.Tangent(AlongX) : B.m_Origin; + // At a 100% radius, opposite arcs can meet with no straight edge. + // A zero-length edge acts as a point-distance source for the outline + // rasterizer and leaves a colored speck at the junction. + if(length(End - Start) > 0.001f) + Shape.m_vContour.push_back({Start, End, -1, + A.m_Active || (AlongX ? A.m_TrimX : A.m_TrimY), + B.m_Active || (AlongX ? B.m_TrimX : B.m_TrimY)}); + } + return Shape; + } + + // Two categories sharing a side use the same tile silhouette. Only the + // higher-priority category draws the line along their common edge. + inline void RemoveLowerPriorityEdges(CShape &Shape, unsigned HigherMask) + { + if(!HigherMask) + return; + const bool Top = Occupied(HigherMask, 0, -1); + const bool Right = Occupied(HigherMask, 1, 0); + const bool Bottom = Occupied(HigherMask, 0, 1); + const bool Left = Occupied(HigherMask, -1, 0); + std::erase_if(Shape.m_vContour, [&](const CSegment &S) { + if(S.m_SharedCorner >= 0) + { + const int X = S.m_SharedCorner == 1 || S.m_SharedCorner == 2 ? 1 : -1; + const int Y = S.m_SharedCorner >= 2 ? 1 : -1; + if(Occupied(HigherMask, X, 0) || Occupied(HigherMask, 0, Y) || Occupied(HigherMask, X, Y)) + return true; + } + const float Epsilon = 0.001f; + return (Top && std::abs(S.m_A.y) < Epsilon && std::abs(S.m_B.y) < Epsilon) || + (Right && std::abs(S.m_A.x - 32) < Epsilon && std::abs(S.m_B.x - 32) < Epsilon) || + (Bottom && std::abs(S.m_A.y - 32) < Epsilon && std::abs(S.m_B.y - 32) < Epsilon) || + (Left && std::abs(S.m_A.x) < Epsilon && std::abs(S.m_B.x) < Epsilon); + }); + std::erase_if(Shape.m_vOutlineJoins, [&](vec2 P) { + return Occupied(HigherMask, P.x > 16 ? 1 : -1, P.y > 16 ? 1 : -1); + }); + } + + inline float BandDistance(const CShape &Shape, vec2 P, float Width) + { + float Result = 1e10f; + for(const auto &S : Shape.m_vContour) + { + const vec2 D = S.m_B - S.m_A; + const float LengthSquared = std::max(dot(D, D), 1e-12f); + const float Projection = dot(P - S.m_A, D) / LengthSquared; + const float T = std::clamp(Projection, 0.0f, 1.0f); + float Band = length(P - (S.m_A + D * T)) - Width; + // Flat caps need a continuous signed half-plane, not an infinite + // distance beyond the endpoint. Interpolating an infinite value while + // clipping a mesh cell cuts off the last part of the straight outline. + if(S.m_ButtA || S.m_ButtB) + { + const float SegmentLength = std::sqrt(LengthSquared); + if(S.m_ButtA) + Band = std::max(Band, -Projection * SegmentLength); + if(S.m_ButtB) + Band = std::max(Band, (Projection - 1.0f) * SegmentLength); + } + Result = std::min(Result, Band); + } + for(const vec2 Join : Shape.m_vOutlineJoins) + Result = std::min(Result, length(P - Join) - Width); + return Result; + } + + // Clip a disjoint tessellation to the inward distance band. Unlike overlapping + // edge strips, this remains single-covered at concave joins and large widths. + inline std::vector> Outline(const CShape &Shape, float Width) + { + std::vector> Result; + if((Shape.m_vContour.empty() && Shape.m_vOutlineJoins.empty()) || Width <= 0) + return Result; + Width = std::min(Width, 16.0f); + // Common straight walls need only a few quads, including at large zoom-out. + const bool Straight = Shape.m_vOutlineJoins.empty() && Shape.m_vQuads.size() == 1 && std::all_of(Shape.m_vContour.begin(), Shape.m_vContour.end(), [](const CSegment &S) { + // The center-sampled fast path draws a whole edge band. A segment + // shortened to meet an arc must use the precise clipped mesh or it + // paints a rectangular stub across the missing tangent interval. + return ((S.m_A.y == S.m_B.y && (S.m_A.y == 0 || S.m_A.y == 32) && std::min(S.m_A.x, S.m_B.x) == 0 && std::max(S.m_A.x, S.m_B.x) == 32) || + (S.m_A.x == S.m_B.x && (S.m_A.x == 0 || S.m_A.x == 32) && std::min(S.m_A.y, S.m_B.y) == 0 && std::max(S.m_A.y, S.m_B.y) == 32)); + }); + if(Straight) + { + const float Grid[] = {0, Width, 32 - Width, 32}; + for(int Y = 0; Y < 3; ++Y) + for(int X = 0; X < 3; ++X) + { + if(Grid[X + 1] <= Grid[X] || Grid[Y + 1] <= Grid[Y]) + continue; + if(BandDistance(Shape, vec2((Grid[X] + Grid[X + 1]) * 0.5f, (Grid[Y] + Grid[Y + 1]) * 0.5f), Width) > 0) + continue; + const vec2 A(Grid[X], Grid[Y]), B(Grid[X + 1], Grid[Y]), C(Grid[X + 1], Grid[Y + 1]), D(Grid[X], Grid[Y + 1]); + Result.push_back({A, B, C}); + Result.push_back({A, C, D}); + } + return Result; + } + for(const auto &Q : Shape.m_vQuads) + { + // The signed distance band is 1-Lipschitz. Skip patches whose + // entire convex hull is farther than the outline width; most of the + // increasingly fine patches at high zoom lie in the tile interior. + const vec2 Center = (Q.m_Pos[0] + Q.m_Pos[1] + Q.m_Pos[2] + Q.m_Pos[3]) * 0.25f; + const float Reach = std::max({length(Q.m_Pos[0] - Center), length(Q.m_Pos[1] - Center), length(Q.m_Pos[2] - Center), length(Q.m_Pos[3] - Center)}); + if(BandDistance(Shape, Center, Width) > Reach) + continue; + const float Size = std::max({length(Q.m_Pos[1] - Q.m_Pos[0]), length(Q.m_Pos[3] - Q.m_Pos[0]), length(Q.m_Pos[2] - Q.m_Pos[1]), length(Q.m_Pos[2] - Q.m_Pos[3])}); + const int N = std::max(2, (int)std::ceil(Size / 2.0f)); + for(int Y = 0; Y < N; ++Y) + for(int X = 0; X < N; ++X) + { + std::array P; + for(int K = 0; K < 4; ++K) + P[K] = Bilinear(Q.m_Pos, (X + (K == 1 || K == 2)) / float(N), (Y + (K >= 2)) / float(N)); + for(int T = 0; T < 2; ++T) + { + const std::array Tri = {P[0], P[T + 1], P[T + 2]}; + std::array Polygon; + int Count = 0; + for(int K = 0; K < 3; ++K) + { + const vec2 A = Tri[K], B = Tri[(K + 1) % 3]; + const float DA = -BandDistance(Shape, A, Width), DB = -BandDistance(Shape, B, Width); + if(DA >= 0) + Polygon[Count++] = A; + if((DA >= 0) != (DB >= 0)) + Polygon[Count++] = mix(A, B, DA / (DA - DB)); + } + for(int K = 1; K + 1 < Count; ++K) + Result.push_back({Polygon[0], Polygon[K], Polygon[K + 1]}); + } + } + } + return Result; + } +} + +#endif diff --git a/src/test/rounded_tiles_test.cpp b/src/test/rounded_tiles_test.cpp new file mode 100644 index 0000000000..61a8f79837 --- /dev/null +++ b/src/test/rounded_tiles_test.cpp @@ -0,0 +1,835 @@ +#include +#include +#include + +#include + +namespace +{ + float Cross(vec2 A, vec2 B) + { + return A.x * B.y - A.y * B.x; + } + + bool InsideTriangle(vec2 P, vec2 A, vec2 B, vec2 C) + { + const float AB = Cross(B - A, P - A); + const float BC = Cross(C - B, P - B); + const float CA = Cross(A - C, P - C); + return AB >= -0.0001f && BC >= -0.0001f && CA >= -0.0001f; + } + + bool InsideShape(const RoundedTiles::CShape &Shape, vec2 P) + { + for(const auto &Q : Shape.m_vQuads) + { + if((Cross(Q.m_Pos[1] - Q.m_Pos[0], Q.m_Pos[2] - Q.m_Pos[0]) > 1e-6f && InsideTriangle(P, Q.m_Pos[0], Q.m_Pos[1], Q.m_Pos[2])) || + (Cross(Q.m_Pos[2] - Q.m_Pos[0], Q.m_Pos[3] - Q.m_Pos[0]) > 1e-6f && InsideTriangle(P, Q.m_Pos[0], Q.m_Pos[2], Q.m_Pos[3]))) + return true; + } + return false; + } + + float Area(const RoundedTiles::CShape &Shape, bool Uv = false) + { + float Result = 0; + for(const auto &Q : Shape.m_vQuads) + { + const auto &P = Uv ? Q.m_Uv : Q.m_Pos; + Result += 0.5f * (Cross(P[1] - P[0], P[2] - P[0]) + Cross(P[2] - P[0], P[3] - P[0])); + } + return Result; + } + + unsigned Mask(const std::vector &Tiles, ivec2 Position) + { + unsigned Result = 0, Bit = 0; + for(int Y = -1; Y <= 1; ++Y) + for(int X = -1; X <= 1; ++X) + if(X || Y) + { + if(std::find(Tiles.begin(), Tiles.end(), Position + ivec2(X, Y)) != Tiles.end()) + Result |= 1u << Bit; + ++Bit; + } + return Result; + } + + vec2 Boundary(const RoundedTiles::CShape &Shape, bool Vertical, float Edge, float T) + { + for(const auto &Q : Shape.m_vQuads) + for(int K = 0; K < 4; ++K) + { + int J = (K + 1) % 4; + vec2 A = Q.m_Uv[K], B = Q.m_Uv[J]; + if(!Vertical) + { + std::swap(A.x, A.y); + std::swap(B.x, B.y); + } + if(std::abs(A.x - Edge) < 1e-6f && std::abs(B.x - Edge) < 1e-6f && std::abs(B.y - A.y) > 1e-6f && T >= std::min(A.y, B.y) - 1e-6f && T <= std::max(A.y, B.y) + 1e-6f) + return mix(Q.m_Pos[K], Q.m_Pos[J], (T - A.y) / (B.y - A.y)); + } + ADD_FAILURE() << "Missing source boundary"; + return vec2(1e6f, 1e6f); + } +} + +TEST(EntityRegions, RoundingMaskOnlyIncludesTheSameVisibleCategory) +{ + CEntityRegions Regions; + Regions.m_Width = Regions.m_Height = 3; + Regions.m_vTypes = { + CEntityRegions::NONE, CEntityRegions::FREEZE, CEntityRegions::NONE, + CEntityRegions::NONE, CEntityRegions::FREEZE, CEntityRegions::SOLID, + CEntityRegions::NONE, CEntityRegions::NONE, CEntityRegions::NONE}; + // The solid tile on the right used to suppress the freeze tile's corner. + EXPECT_EQ(Regions.Mask(1, 1, CEntityRegions::FREEZE), 1u << 1); + // The map's right border repeats its last column when extended on screen. + EXPECT_EQ(Regions.Mask(2, 1, CEntityRegions::SOLID), 1u << 4); + EXPECT_EQ(Regions.PriorityMask(1, 1, CEntityRegions::FREEZE), (1u << 1) | (1u << 4)); + EXPECT_EQ(Regions.BlockerMask(1, 1, CEntityRegions::FREEZE), 1u << 4); +} + +TEST(EntityRegions, ThroughHookOverlaySharesTheRoundedSolidSilhouette) +{ + CEntityRegions Regions; + Regions.m_Width = Regions.m_Height = 1; + Regions.m_vTypes = {CEntityRegions::SOLID}; + Regions.m_vTopLayers = {0}; + EXPECT_TRUE(Regions.ShouldRoundTile(0, 0, 0, TILE_SOLID)); + EXPECT_TRUE(Regions.ShouldRoundTile(0, 0, 1, TILE_THROUGH)); + EXPECT_TRUE(Regions.ShouldRoundTile(0, 0, 1, TILE_THROUGH_CUT)); + EXPECT_TRUE(Regions.ShouldRoundTile(0, 0, 1, TILE_THROUGH_ALL)); + EXPECT_TRUE(Regions.ShouldRoundTile(0, 0, 1, TILE_THROUGH_DIR)); + const auto Rounded = RoundedTiles::Build(0, 16, 2, 16); + EXPECT_LT(Area(Rounded), 850.0f); + EXPECT_NEAR(Area(Rounded, true), 1.0f, 0.001f); + Regions.m_vTopLayers = {1}; + EXPECT_FALSE(Regions.ShouldRoundTile(0, 0, 0, TILE_SOLID)); + EXPECT_FALSE(Regions.ShouldRoundTile(0, 0, 1, TILE_THROUGH)); + EXPECT_TRUE(Regions.ShouldRoundTile(0, 0, 1, TILE_NOHOOK)); +} + +TEST(EntityRegions, ThroughOverlayDoesNotSuppressAdjacentJunctions) +{ + CEntityRegions Regions; + Regions.m_Width = Regions.m_Height = 3; + Regions.m_vTypes.assign(9, CEntityRegions::FREEZE); + Regions.m_vTypes[4] = CEntityRegions::SOLID; + EXPECT_NE(Regions.ComplementCorners(1, 1), 0u); +} + +TEST(EntityRegions, VisibleTeleporterSuppressesCoveredUnfreezeOutline) +{ + // Felian places unfreeze in the game layer underneath tele tiles. The + // later tele layer is visible, including where its number is zero. + EXPECT_EQ(CEntityRegions::VisibleType(TILE_UNFREEZE, 0, TILE_TELEIN), CEntityRegions::TELE); + EXPECT_EQ(CEntityRegions::VisibleType(TILE_SOLID, TILE_FREEZE, TILE_TELEIN), CEntityRegions::TELE); + EXPECT_EQ(CEntityRegions::VisibleType(TILE_SOLID, TILE_FREEZE, 0), CEntityRegions::FREEZE); +} + +TEST(EntityRegions, BuriedUnfreezeDoesNotJoinAdjacentTeleInnerCorner) +{ + const int OnUnfreeze = CEntityRegions::VisibleRegionKey(TILE_UNFREEZE, 0, TILE_TELEINEVIL); + const int OnAir = CEntityRegions::VisibleRegionKey(0, 0, TILE_TELEINEVIL); + EXPECT_NE(OnUnfreeze, OnAir); + EXPECT_NE(OnAir, CEntityRegions::VisibleRegionKey(0, 0, TILE_SOLID)); + CEntityRegions Regions; + Regions.m_Width = Regions.m_Height = 3; + Regions.m_vTypes = { + CEntityRegions::NONE, CEntityRegions::TELE, CEntityRegions::NONE, + CEntityRegions::TELE, CEntityRegions::TELE, CEntityRegions::NONE, + CEntityRegions::NONE, CEntityRegions::TELE, CEntityRegions::NONE}; + Regions.m_vRegionKeys = {0, OnAir, 0, OnUnfreeze, OnAir, 0, 0, OnAir, 0}; + const unsigned Mask = Regions.Mask(1, 1, CEntityRegions::TELE); + const unsigned Blockers = Regions.BlockerMask(1, 1, CEntityRegions::TELE); + EXPECT_FALSE(RoundedTiles::Corner(Mask, -1, -1, 16, 2, Blockers).m_Active); +} + +TEST(EntityRegions, SwitchTileBlocksUnfreezeCorner) +{ + EXPECT_EQ(CEntityRegions::VisibleType(TILE_UNFREEZE, 0, 0, TILE_SWITCHTIMEDOPEN), CEntityRegions::SWITCH); + CEntityRegions Regions; + Regions.m_Width = Regions.m_Height = 3; + Regions.m_vTypes = { + CEntityRegions::NONE, CEntityRegions::NONE, CEntityRegions::NONE, + CEntityRegions::NONE, CEntityRegions::UNFREEZE, CEntityRegions::SWITCH, + CEntityRegions::NONE, CEntityRegions::NONE, CEntityRegions::NONE}; + constexpr unsigned East = 1u << 4; + EXPECT_EQ(Regions.BlockerMask(1, 1, CEntityRegions::UNFREEZE), East); + EXPECT_EQ(Regions.PriorityMask(1, 1, CEntityRegions::UNFREEZE), East); + EXPECT_FALSE(RoundedTiles::Corner(Regions.Mask(1, 1, CEntityRegions::UNFREEZE), 1, -1, 16, 2, + Regions.BlockerMask(1, 1, CEntityRegions::UNFREEZE)) + .m_Active); +} + +TEST(EntityRegions, CoveredMaterialJunctionUsesMatchingInnerAndOuterCorners) +{ + CEntityRegions Regions; + Regions.m_Width = Regions.m_Height = 3; + Regions.m_vTypes.assign(9, CEntityRegions::FREEZE); + Regions.m_vTypes[4] = CEntityRegions::SOLID; + EXPECT_EQ(Regions.ComplementCorners(1, 1), 15u); + EXPECT_EQ(Regions.ComplementCorners(0, 0) & 4u, 4u); + EXPECT_EQ(Regions.ComplementCorners(2, 0) & 8u, 8u); + EXPECT_EQ(Regions.ComplementCorners(0, 2) & 2u, 2u); + EXPECT_EQ(Regions.ComplementCorners(2, 2) & 1u, 1u); + EXPECT_EQ((Regions.TransitionCorners(1, 0) >> 4) & 12u, 12u); + + // Same visible asset over different buried layers is not a material seam. + Regions.m_vTypes[4] = CEntityRegions::FREEZE; + Regions.m_vRegionKeys.assign(9, CEntityRegions::FREEZE); + Regions.m_vRegionKeys[4] = CEntityRegions::FREEZE + 2048; + EXPECT_EQ(Regions.ComplementCorners(1, 1), 0u); +} + +TEST(EntityRegions, EmptyInnerJunctionTrimsTheTwoSideTiles) +{ + // The diagonal solid owns the inward arc. The two solids touching the + // empty cell by an edge must stop their contours at its tangents. + CEntityRegions Regions; + Regions.m_Width = Regions.m_Height = 3; + Regions.m_vTypes = { + CEntityRegions::SOLID, CEntityRegions::SOLID, CEntityRegions::NONE, + CEntityRegions::SOLID, CEntityRegions::NONE, CEntityRegions::NONE, + CEntityRegions::NONE, CEntityRegions::NONE, CEntityRegions::NONE}; + EXPECT_NE(Regions.TransitionCorners(1, 0) & (1u << (3 + 4)), 0u); + EXPECT_NE(Regions.TransitionCorners(0, 1) & (1u << (1 + 4)), 0u); + for(const ivec2 Tile : {ivec2(1, 0), ivec2(0, 1)}) + { + const unsigned Mask = Regions.Mask(Tile.x, Tile.y, CEntityRegions::SOLID); + const auto Shape = RoundedTiles::Build(Mask, 16, 2, 8, + Regions.BlockerMask(Tile.x, Tile.y, CEntityRegions::SOLID), Regions.TransitionCorners(Tile.x, Tile.y)); + const vec2 Corner = Tile.x ? vec2(0, 32) : vec2(32, 0); + const vec2 Vertex = Tile.x ? Boundary(Shape, true, 0, 1) : Boundary(Shape, false, 0, 1); + EXPECT_LT(length(Vertex - Corner), 0.001f); + } + const auto OuterOnly = RoundedTiles::Build(Regions.Mask(1, 0, CEntityRegions::SOLID), 16, 0, 8, + Regions.BlockerMask(1, 0, CEntityRegions::SOLID), Regions.TransitionCorners(1, 0)); + EXPECT_LT(length(Boundary(OuterOnly, true, 0, 1) - vec2(0, 32)), 0.001f); +} + +TEST(RoundedTiles, EmptyInnerJunctionHasOneSurface) +{ + CEntityRegions Regions; + Regions.m_Width = Regions.m_Height = 3; + Regions.m_vTypes = { + CEntityRegions::SOLID, CEntityRegions::SOLID, CEntityRegions::NONE, + CEntityRegions::SOLID, CEntityRegions::NONE, CEntityRegions::NONE, + CEntityRegions::NONE, CEntityRegions::NONE, CEntityRegions::NONE}; + std::vector> Shapes; + for(const ivec2 Tile : {ivec2(0, 0), ivec2(1, 0), ivec2(0, 1)}) + Shapes.emplace_back(Tile, RoundedTiles::Build(Regions.Mask(Tile.x, Tile.y, CEntityRegions::SOLID), 16, 2, 16, + Regions.BlockerMask(Tile.x, Tile.y, CEntityRegions::SOLID), Regions.TransitionCorners(Tile.x, Tile.y))); + int Overlaps = 0; + for(int Y = 0; Y < 128; ++Y) + for(int X = 0; X < 128; ++X) + { + const vec2 P(16.125f + X * 0.375f, 16.125f + Y * 0.375f); + int Cover = 0; + for(const auto &[Tile, Shape] : Shapes) + Cover += InsideShape(Shape, P - vec2(Tile.x * 32.0f, Tile.y * 32.0f)); + if(P.x >= 32 && P.y >= 32 && std::abs(length(P - vec2(48, 48)) - 16) < 0.6f) + continue; + Overlaps += Cover > 1; + } + EXPECT_EQ(Overlaps, 0); +} + +TEST(RoundedTiles, TrimmedStraightOutlineStopsAtTheArcTangent) +{ + // North-east side tile of a three-solid/one-empty junction. Only the + // final half of its bottom edge is exposed after the diagonal arc. + CEntityRegions Regions; + Regions.m_Width = Regions.m_Height = 3; + Regions.m_vTypes = { + CEntityRegions::SOLID, CEntityRegions::SOLID, CEntityRegions::SOLID, + CEntityRegions::SOLID, CEntityRegions::NONE, CEntityRegions::NONE, + CEntityRegions::NONE, CEntityRegions::NONE, CEntityRegions::NONE}; + const auto Shape = RoundedTiles::Build(Regions.Mask(1, 0, CEntityRegions::SOLID), 16, 2, 16, + Regions.BlockerMask(1, 0, CEntityRegions::SOLID), Regions.TransitionCorners(1, 0)); + const auto Outline = RoundedTiles::Outline(Shape, 2); + const auto Covers = [&](vec2 P) { + for(const auto &T : Outline) + if(Cross(T[1] - T[0], T[2] - T[0]) > 1e-7f && InsideTriangle(P, T[0], T[1], T[2])) + return true; + return false; + }; + EXPECT_FALSE(Covers(vec2(3, 31))); + // A shortened edge must have a flat cap at the tangent. Treating its + // endpoint as a distance-field point adds the triangular purple spur. + // The arc's inward band reaches into this side tile before its tangent. + EXPECT_TRUE(Covers(vec2(15, 31))); + EXPECT_FALSE(Covers(vec2(9, 31))); + EXPECT_TRUE(Covers(vec2(20, 31))); + EXPECT_TRUE(Covers(vec2(25, 31))); +} + +TEST(RoundedTiles, FlatOutlineCapDoesNotLoseTheLastMeshCell) +{ + CEntityRegions Regions; + Regions.m_Width = Regions.m_Height = 3; + Regions.m_vTypes = { + CEntityRegions::SOLID, CEntityRegions::SOLID, CEntityRegions::SOLID, + CEntityRegions::SOLID, CEntityRegions::NONE, CEntityRegions::NONE, + CEntityRegions::NONE, CEntityRegions::NONE, CEntityRegions::NONE}; + const float Radius = 12.3f; + auto Shape = RoundedTiles::Build(Regions.Mask(1, 0, CEntityRegions::SOLID), Radius, 2, 8, + Regions.BlockerMask(1, 0, CEntityRegions::SOLID), Regions.TransitionCorners(1, 0)); + // Isolate the straight cap; the neighboring arc also covers this area. + std::erase_if(Shape.m_vContour, [](const auto &S) { return S.m_A.y != 32 || S.m_B.y != 32; }); + const auto Outline = RoundedTiles::Outline(Shape, 2); + const auto Covers = [&](vec2 P) { + return std::any_of(Outline.begin(), Outline.end(), [&](const auto &T) { + return Cross(T[1] - T[0], T[2] - T[0]) > 1e-7f && InsideTriangle(P, T[0], T[1], T[2]); + }); + }; + EXPECT_FALSE(Covers(vec2(Radius - 0.1f, 31))); + EXPECT_TRUE(Covers(vec2(Radius + 0.1f, 31))); +} + +TEST(RoundedTiles, InnerArcOutlineContinuesInsideBothSideTiles) +{ + CEntityRegions Regions; + Regions.m_Width = Regions.m_Height = 3; + Regions.m_vTypes = { + CEntityRegions::SOLID, CEntityRegions::SOLID, CEntityRegions::SOLID, + CEntityRegions::SOLID, CEntityRegions::NONE, CEntityRegions::NONE, + CEntityRegions::SOLID, CEntityRegions::NONE, CEntityRegions::NONE}; + for(const float Radius : {4.0f, 12.3f, 16.0f}) + for(const int Mode : {1, 2}) + for(const int Steps : {2, 4, 8, 16}) + for(const ivec2 Tile : {ivec2(1, 0), ivec2(0, 1)}) + { + const auto Shape = RoundedTiles::Build(Regions.Mask(Tile.x, Tile.y, CEntityRegions::SOLID), Radius, Mode, Steps, + Regions.BlockerMask(Tile.x, Tile.y, CEntityRegions::SOLID), Regions.TransitionCorners(Tile.x, Tile.y)); + const auto Outline = RoundedTiles::Outline(Shape, 2); + const vec2 P = Tile.x ? vec2(Radius - 0.25f, 31) : vec2(31, Radius - 0.25f); + EXPECT_TRUE(std::any_of(Outline.begin(), Outline.end(), [&](const auto &T) { + return Cross(T[1] - T[0], T[2] - T[0]) > 1e-7f && InsideTriangle(P, T[0], T[1], T[2]); + })) << "radius=" + << Radius << " mode=" << Mode << " steps=" << Steps << " tile=" << Tile.x << ',' << Tile.y; + } +} + +TEST(RoundedTiles, ThroughArtworkContinuesIntoTheDiagonalWedge) +{ + const auto Shape = RoundedTiles::Build((1u << 3) | (1u << 6), 16, 2, 8, 0, 1u << 3); + int Wedges = 0; + for(const auto &Q : Shape.m_vQuads) + { + if(length(Q.m_RepeatOffset) < 0.5f) + continue; + ++Wedges; + for(int K = 0; K < 4; ++K) + { + const vec2 UV = RoundedTiles::SampleUv(Q, K, true); + EXPECT_GE(UV.x, -0.0001f); + EXPECT_LE(UV.x, 1.0001f); + EXPECT_GE(UV.y, -0.0001f); + EXPECT_LE(UV.y, 1.0001f); + EXPECT_LT(length(UV + Q.m_RepeatOffset - Q.m_Pos[K] / 32.0f), 0.0001f); + } + } + EXPECT_GT(Wedges, 0); +} + +TEST(RoundedTiles, TwoMaterialsPartitionCoveredCornerWithoutGaps) +{ + CEntityRegions Regions; + Regions.m_Width = Regions.m_Height = 3; + Regions.m_vTypes.assign(9, CEntityRegions::FREEZE); + Regions.m_vTypes[4] = CEntityRegions::SOLID; + for(float Radius : {0.16f, 8.0f, 16.0f}) + for(int Mode = 0; Mode < 3; ++Mode) + { + std::vector> Shapes; + for(int Y = 0; Y < 3; ++Y) + for(int X = 0; X < 3; ++X) + { + const int Type = Regions.Get(X, Y); + Shapes.emplace_back(ivec2(X, Y), RoundedTiles::Build(Regions.Mask(X, Y, Type), Radius, Mode, 16, + Regions.BlockerMask(X, Y, Type), Regions.TransitionCorners(X, Y))); + } + int Mismatches = 0; + std::vector FirstMismatches; + int FirstCover = -1; + for(int Y = 0; Y < 64; ++Y) + for(int X = 0; X < 64; ++X) + { + const vec2 P(32.125f + X * 0.5f, 32.125f + Y * 0.5f); + int Cover = 0; + for(const auto &[Tile, Shape] : Shapes) + Cover += InsideShape(Shape, P - vec2(Tile.x * 32.0f, Tile.y * 32.0f)); + if(Cover != 1) + { + ++Mismatches; + if(FirstMismatches.empty()) + FirstCover = Cover; + if(FirstMismatches.size() < 4) + FirstMismatches.emplace_back(X, Y); + } + } + EXPECT_EQ(Mismatches, 0) << Radius << "," << Mode << " first " << (FirstMismatches.empty() ? -1 : FirstMismatches[0].x) << "," << (FirstMismatches.empty() ? -1 : FirstMismatches[0].y) << " cover " << FirstCover; + } +} + +TEST(RoundedTiles, SharedMaterialArcHasExactlyOneOutlineOwner) +{ + for(const auto Pair : {std::pair{CEntityRegions::FREEZE, CEntityRegions::SOLID}, + std::pair{CEntityRegions::SOLID, CEntityRegions::FREEZE}}) + { + CEntityRegions Regions; + Regions.m_Width = Regions.m_Height = 3; + Regions.m_vTypes.assign(9, Pair.first); + Regions.m_vTypes[4] = Pair.second; + int Before = 0, After = 0; + for(const ivec2 Tile : {ivec2(0, 0), ivec2(1, 1)}) + { + const int SharedCorner = Tile == ivec2(0, 0) ? 2 : 0; + const int Type = Regions.Get(Tile.x, Tile.y); + const unsigned Mask = Regions.Mask(Tile.x, Tile.y, Type); + auto Shape = RoundedTiles::Build(Mask, 16, 2, 8, + Regions.BlockerMask(Tile.x, Tile.y, Type), Regions.TransitionCorners(Tile.x, Tile.y)); + Before += std::count_if(Shape.m_vContour.begin(), Shape.m_vContour.end(), [&](const auto &S) { return S.m_SharedCorner == SharedCorner; }); + RoundedTiles::RemoveLowerPriorityEdges(Shape, Regions.PriorityMask(Tile.x, Tile.y, Type) & ~Mask); + After += std::count_if(Shape.m_vContour.begin(), Shape.m_vContour.end(), [&](const auto &S) { return S.m_SharedCorner == SharedCorner; }); + } + EXPECT_EQ(Before, 32); + EXPECT_EQ(After, 16); + } +} + +TEST(RoundedTiles, NeighborOutlineStopsAtCoveredArcTangent) +{ + CEntityRegions Regions; + Regions.m_Width = Regions.m_Height = 3; + Regions.m_vTypes.assign(9, CEntityRegions::FREEZE); + Regions.m_vTypes[4] = CEntityRegions::SOLID; + const unsigned Mask = Regions.Mask(1, 0, CEntityRegions::FREEZE); + const auto Shape = RoundedTiles::Build(Mask, 8, 2, 8, + Regions.BlockerMask(1, 0, CEntityRegions::FREEZE), Regions.TransitionCorners(1, 0)); + int BottomEdges = 0; + for(const auto &S : Shape.m_vContour) + if(S.m_A.y == 32 && S.m_B.y == 32 && S.m_A.x != S.m_B.x) + { + ++BottomEdges; + EXPECT_FLOAT_EQ(std::min(S.m_A.x, S.m_B.x), 8); + EXPECT_FLOAT_EQ(std::max(S.m_A.x, S.m_B.x), 24); + } + EXPECT_EQ(BottomEdges, 1); +} + +TEST(RoundedTiles, DifferentCategoryBlocksCurvesAcrossItsCell) +{ + constexpr unsigned East = 1u << 4; + constexpr unsigned South = 1u << 6; + constexpr unsigned SouthEast = 1u << 7; + // Three tiles of one category used to expand into a fourth category. + const auto WithoutBlocker = RoundedTiles::Build(East | South, 16, 1, 16); + const auto WithBlocker = RoundedTiles::Build(East | South, 16, 1, 16, SouthEast); + EXPECT_GT(Area(WithoutBlocker), 1024); + EXPECT_FLOAT_EQ(Area(WithBlocker), 1024); + // A neighboring category sharing the top-right edge prevents an outer + // curve from cutting a hole between the two visible tiles. + EXPECT_TRUE(RoundedTiles::Corner(0, 1, -1, 16, 0).m_Active); + EXPECT_FALSE(RoundedTiles::Corner(0, 1, -1, 16, 0, East).m_Active); +} + +TEST(RoundedTiles, OutlineDoesNotInventAnInnerArcAtCategoryJunction) +{ + constexpr unsigned East = 1u << 4; + constexpr unsigned NorthEast = 1u << 2; + const auto TileCorner = RoundedTiles::Corner(East, 1, -1, 16, 2, NorthEast); + const auto OldOutlineCorner = RoundedTiles::Corner(East | NorthEast, 1, -1, 16, 2, NorthEast); + EXPECT_FALSE(TileCorner.m_Active); + EXPECT_TRUE(OldOutlineCorner.m_Active); + + auto Shape = RoundedTiles::Build(0, 16, 2, 16, East); + const auto CountRightEdges = [](const RoundedTiles::CShape &S) { + return std::count_if(S.m_vContour.begin(), S.m_vContour.end(), [](const RoundedTiles::CSegment &Edge) { + return Edge.m_A.x == 32 && Edge.m_B.x == 32 && Edge.m_A.y != Edge.m_B.y; + }); + }; + EXPECT_GT(CountRightEdges(Shape), 0); + RoundedTiles::RemoveLowerPriorityEdges(Shape, East); + EXPECT_EQ(CountRightEdges(Shape), 0); +} + +TEST(RoundedTiles, BlockedInnerJunctionJoinsStraightOutlineBands) +{ + // Three outlined tiles surround a different category to the north-west. + // Their north and west bands end in different cells; the south-east tile + // must bridge them without changing any tile artwork or creating an arc. + constexpr unsigned North = 1u << 1; + constexpr unsigned West = 1u << 3; + constexpr unsigned NorthWest = 1u << 0; + auto Shape = RoundedTiles::Build(North | West, 16, 2, 4, NorthWest); + ASSERT_EQ(Shape.m_vOutlineJoins.size(), 1u); + EXPECT_EQ(RoundedTiles::Build(North | West, 16, 0, 4).m_vOutlineJoins.size(), 1u); + EXPECT_LT(length(Shape.m_vOutlineJoins[0]), 0.001f); + const auto CoversJoin = [](const RoundedTiles::CShape &S) { + for(const auto &T : RoundedTiles::Outline(S, 2)) + if(Cross(T[1] - T[0], T[2] - T[0]) > 0.0001f && InsideTriangle(vec2(0.5f, 0.5f), T[0], T[1], T[2])) + return true; + return false; + }; + EXPECT_TRUE(CoversJoin(Shape)); + Shape.m_vOutlineJoins.clear(); + EXPECT_FALSE(CoversJoin(Shape)); + Shape = RoundedTiles::Build(North | West, 16, 2, 4, NorthWest); + RoundedTiles::RemoveLowerPriorityEdges(Shape, NorthWest); + EXPECT_TRUE(Shape.m_vOutlineJoins.empty()); +} + +TEST(RoundedTiles, StairOutlineHasOneFourConnectedRasterComponent) +{ + const std::vector Tiles = {ivec2(1, 0), ivec2(0, 1), ivec2(1, 1)}; + const auto CountComponents = [&](bool AddJoin) { + std::array Pixels{}; + for(const ivec2 Tile : Tiles) + { + const unsigned Blocker = Tile == ivec2(1, 0) ? 1u << 3 : Tile == ivec2(0, 1) ? 1u << 1 : + 1u << 0; + auto Shape = RoundedTiles::Build(Mask(Tiles, Tile), 16, 2, 4, Blocker); + if(!AddJoin) + Shape.m_vOutlineJoins.clear(); + const auto Triangles = RoundedTiles::Outline(Shape, 2); + for(int Y = 0; Y < 16; ++Y) + for(int X = 0; X < 16; ++X) + { + const vec2 P(30.0f + (X + 0.5f) / 4.0f - Tile.x * 32.0f, 30.0f + (Y + 0.5f) / 4.0f - Tile.y * 32.0f); + for(const auto &T : Triangles) + if(Cross(T[1] - T[0], T[2] - T[0]) > 0.0001f && InsideTriangle(P, T[0], T[1], T[2])) + { + Pixels[Y * 16 + X] = true; + break; + } + } + } + int Components = 0; + for(int I = 0; I < 16 * 16; ++I) + { + if(!Pixels[I]) + continue; + ++Components; + std::vector Queue{I}; + Pixels[I] = false; + for(size_t Head = 0; Head < Queue.size(); ++Head) + { + const int Current = Queue[Head]; + for(const ivec2 D : {ivec2(1, 0), ivec2(-1, 0), ivec2(0, 1), ivec2(0, -1)}) + { + const int X = Current % 16 + D.x, Y = Current / 16 + D.y; + if(X < 0 || X >= 16 || Y < 0 || Y >= 16 || !Pixels[Y * 16 + X]) + continue; + Pixels[Y * 16 + X] = false; + Queue.push_back(Y * 16 + X); + } + } + } + return Components; + }; + EXPECT_EQ(CountComponents(false), 2); + EXPECT_EQ(CountComponents(true), 1); +} + +TEST(RoundedTiles, DisabledIsOneSquare) +{ + for(unsigned M = 0; M < 256; ++M) + { + const auto S = RoundedTiles::Build(M, 0, 2, 4); + ASSERT_EQ(S.m_vQuads.size(), 1u); + EXPECT_FLOAT_EQ(Area(S), 1024); + EXPECT_FLOAT_EQ(Area(S, true), 1); + } +} + +TEST(RoundedTiles, MaximumIsCircleAndPreservesEntireUv) +{ + const auto S = RoundedTiles::Build(0, 16, 2, 32); + EXPECT_NEAR(Area(S), pi * 256, 0.2f); + EXPECT_NEAR(Area(S, true), 1, 1e-5f); + for(const auto &Q : S.m_vQuads) + for(int K = 0; K < 4; ++K) + { + const vec2 Uv = Q.m_Uv[K]; + if(Uv.x == 0 || Uv.x == 1 || Uv.y == 0 || Uv.y == 1) + EXPECT_NEAR(length(Q.m_Pos[K] - vec2(16, 16)), 16, 1e-4f); + } +} + +TEST(RoundedTiles, InnerJunctionFillsHoleWithoutOverlap) +{ + const std::vector Tiles = {ivec2(0, 0), ivec2(0, 1), ivec2(1, 1)}; + for(float R : {0.16f, 8.0f, 16.0f}) + { + float Sum = 0; + for(const auto &P : Tiles) + Sum += Area(RoundedTiles::Build(Mask(Tiles, P), R, 1, 32)); + EXPECT_NEAR(Sum, 3072 + R * R * (1 - pi / 4), 0.1f); + } +} + +TEST(RoundedTiles, InnerJunctionMatchesCircularUnion) +{ + // NW, SW and SE are filled; NE is the missing cell. The rounded inner + // boundary in NE is a quarter-circle centered at (48, 16). + const std::vector Tiles = {ivec2(0, 0), ivec2(0, 1), ivec2(1, 1)}; + std::vector> Shapes; + for(const auto &Tile : Tiles) + Shapes.emplace_back(Tile, RoundedTiles::Build(Mask(Tiles, Tile), 16, 2, 16)); + int Mismatches = 0; + for(int Y = 0; Y < 64; ++Y) + for(int X = 0; X < 64; ++X) + { + const vec2 P(32.125f + X * 0.25f, 16.125f + Y * 0.25f); + const float D = length(P - vec2(48, 16)); + if(std::abs(D - 16) < 0.5f) + continue; + bool Filled = false; + for(const auto &[Tile, Shape] : Shapes) + Filled |= InsideShape(Shape, P - vec2(Tile.x * 32.0f, Tile.y * 32.0f)); + Mismatches += Filled != (D > 16); + } + EXPECT_EQ(Mismatches, 0); +} + +TEST(RoundedTiles, InnerJunctionOutlineStaysInsideUnion) +{ + const std::vector Tiles = {ivec2(0, 0), ivec2(0, 1), ivec2(1, 1)}; + int OutsideTriangles = 0; + for(const auto &Tile : Tiles) + { + const auto Shape = RoundedTiles::Build(Mask(Tiles, Tile), 16, 2, 16); + for(const auto &Triangle : RoundedTiles::Outline(Shape, 2)) + { + const vec2 P = (Triangle[0] + Triangle[1] + Triangle[2]) / 3.0f + vec2(Tile.x * 32.0f, Tile.y * 32.0f); + if(P.x > 32 && P.x < 48 && P.y > 16 && P.y < 32 && length(P - vec2(48, 16)) < 15.5f) + ++OutsideTriangles; + } + } + EXPECT_EQ(OutsideTriangles, 0); +} + +TEST(RoundedTiles, InnerJunctionKeepsArtworkInOriginalCell) +{ + // The missing south-east neighbor expands this tile into its cell. The + // original border/art must remain at its original map coordinates. UVs + // outside the cell are clamped by the sampler per fragment, not at mesh + // vertices: vertex clamping blurs the border at lower mesh detail. + const auto Shape = RoundedTiles::Build(255u ^ 128u, 16, 1, 16); + float MaxUvError = 0.0f; + bool Extrapolates = false; + for(const auto &Q : Shape.m_vQuads) + for(int K = 0; K < 4; ++K) + { + const vec2 Expected = Q.m_Pos[K] / 32.0f; + MaxUvError = std::max(MaxUvError, length(Q.m_SampleUv[K] - Expected)); + Extrapolates |= Q.m_SampleUv[K].x > 1.0f || Q.m_SampleUv[K].y > 1.0f; + } + EXPECT_LT(MaxUvError, 1e-5f); + EXPECT_TRUE(Extrapolates); +} + +TEST(RoundedTiles, OutlineHasNoPointSourcesAtCategoryJunctions) +{ + // A zero-length contour at a square junction paints an isolated triangular + // spot when the surrounding straight edge is covered by another category. + for(unsigned Mask = 0; Mask < 256; ++Mask) + for(int Mode = 0; Mode < 3; ++Mode) + { + const auto Shape = RoundedTiles::Build(Mask, 16, Mode, 4); + for(const auto &Segment : Shape.m_vContour) + EXPECT_GT(length(Segment.m_B - Segment.m_A), 0.001f) << Mask << "," << Mode; + } +} + +TEST(RoundedTiles, ModesAndInterior) +{ + EXPECT_FLOAT_EQ(Area(RoundedTiles::Build(0, 16, 1, 4)), 1024); + EXPECT_EQ(RoundedTiles::Build(255, 16, 2, 4).m_vQuads.size(), 1u); + EXPECT_TRUE(RoundedTiles::Build(255, 16, 2, 4).m_vContour.empty()); + const auto Inner = RoundedTiles::Corner(255 ^ 128, 1, 1, 16, 1); + EXPECT_TRUE(Inner.m_Inner); + EXPECT_FALSE(RoundedTiles::Corner(255 ^ 128, 1, 1, 16, 0).m_Active); +} + +TEST(RoundedTiles, EveryNeighborhoodHasPositiveGeometryAndFullUv) +{ + for(int Mode = 0; Mode < 3; ++Mode) + for(float R : {0.16f, 8.0f, 15.84f, 16.0f}) + for(unsigned M = 0; M < 256; ++M) + { + const auto S = RoundedTiles::Build(M, R, Mode, 4); + ASSERT_NEAR(Area(S, true), 1, 2e-5f) << M << "," << Mode << "," << R; + for(const auto &Q : S.m_vQuads) + { + ASSERT_GE(Cross(Q.m_Pos[1] - Q.m_Pos[0], Q.m_Pos[2] - Q.m_Pos[0]), -0.0001f) << M << "," << Mode; + ASSERT_GE(Cross(Q.m_Pos[2] - Q.m_Pos[0], Q.m_Pos[3] - Q.m_Pos[0]), -0.0001f) << M << "," << Mode; + } + } +} + +TEST(RoundedTiles, SharedEdgesStayWatertight) +{ + // Exhaust the occupancy of a 3x3 neighborhood, including three-way joins. + for(unsigned Bits = 0; Bits < 512; ++Bits) + { + std::vector Tiles; + for(int I = 0; I < 9; ++I) + if(Bits & (1u << I)) + Tiles.emplace_back(I % 3, I / 3); + for(const auto &P : Tiles) + for(const ivec2 D : {ivec2(1, 0), ivec2(0, 1)}) + { + if(std::find(Tiles.begin(), Tiles.end(), P + D) == Tiles.end()) + continue; + for(int Mode = 0; Mode < 3; ++Mode) + { + const auto A = RoundedTiles::Build(Mask(Tiles, P), 16, Mode, 4); + const auto B = RoundedTiles::Build(Mask(Tiles, P + D), 16, Mode, 4); + for(int I = 0; I <= 16; ++I) + { + const vec2 PA = Boundary(A, D.x != 0, 1, I / 16.0f); + const vec2 PB = Boundary(B, D.x != 0, 0, I / 16.0f) + vec2(D.x * 32, D.y * 32); + ASSERT_LT(length(PA - PB), 0.0001f) << Bits << "," << Mode; + } + } + } + } +} + +TEST(RoundedTiles, OutlineIsInsideAndSingleCovered) +{ + const auto Shape = RoundedTiles::Build(0, 16, 2, 16); + for(float Width : {1.0f, 2.0f, 8.0f, 16.0f}) + { + const auto Triangles = RoundedTiles::Outline(Shape, Width); + float Sum = 0; + for(const auto &T : Triangles) + { + const float A = Cross(T[1] - T[0], T[2] - T[0]) * 0.5f; + ASSERT_GE(A, -1e-5f); + Sum += A; + for(const auto &P : T) + ASSERT_LE(length(P - vec2(16, 16)), 16.0001f); + } + EXPECT_LE(Sum, Area(Shape) + 0.01f); + EXPECT_NEAR(Sum, pi * (256 - (16 - Width) * (16 - Width)), 1.5f); + } +} + +TEST(RoundedTiles, DetailMeetsPixelError) +{ + for(float Pixels : {0.1f, 1.0f, 4.0f, 32.0f, 100.0f}) + { + int N = RoundedTiles::Detail(16, Pixels); + EXPECT_LE(16 * Pixels * (1 - std::cos(pi / (8 * N))), 0.2501f); + } +} + +TEST(EntityOutlineIndex, EmptyMapAndEmptyView) +{ + CEntityOutlineIndex Index; + CEntityRegions Regions; + Index.Build(Regions); + int Count = 0; + auto Draw = [&](const auto &, vec2, vec2) { ++Count; }; + Index.Visit(-100000, -100000, 100000, 100000, Draw); + EXPECT_EQ(Count, 0); + Regions.m_Width = Regions.m_Height = 3; + Regions.m_vTypes.assign(9, CEntityRegions::NONE); + Regions.m_vTypes[4] = CEntityRegions::SOLID; + Index.Build(Regions); + Index.Visit(1, 1, 1, 2, Draw); + Index.Visit(1, 2, 2, 1, Draw); + EXPECT_EQ(Count, 0); + Index.Visit(1, 1, 2, 2, Draw); + EXPECT_EQ(Count, 1); +} + +TEST(EntityOutlineIndex, NeighborhoodMatchesRegionQueries) +{ + for(unsigned Mask = 0; Mask < 256; ++Mask) + { + CEntityRegions Regions; + Regions.m_Width = Regions.m_Height = 3; + Regions.m_vTypes.assign(9, CEntityRegions::FREEZE); + Regions.m_vTypes[4] = CEntityRegions::SOLID; + for(int Y = -1; Y <= 1; ++Y) + for(int X = -1; X <= 1; ++X) + if(RoundedTiles::Occupied(Mask, X, Y)) + Regions.m_vTypes[(Y + 1) * 3 + X + 1] = CEntityRegions::SOLID; + CEntityOutlineIndex Index; + Index.Build(Regions); + int Count = 0; + Index.Visit(1, 1, 2, 2, [&](const auto &Cell, vec2 Position, vec2 Scale) { + ++Count; + EXPECT_EQ(Cell.m_Type, CEntityRegions::SOLID); + EXPECT_EQ(Cell.m_Mask, Regions.Mask(1, 1, Cell.m_Type)); + EXPECT_EQ(Cell.m_Blockers, Regions.BlockerMask(1, 1, Cell.m_Type)); + EXPECT_EQ(Cell.m_Transition, Regions.TransitionCorners(1, 1)); + EXPECT_EQ(Cell.m_HigherMask, Regions.PriorityMask(1, 1, Cell.m_Type) & ~Cell.m_Mask); + EXPECT_EQ(Position, vec2(32, 32)); + EXPECT_EQ(Scale, vec2(1, 1)); + }); + EXPECT_EQ(Count, Mask == 255 ? 0 : 1); + } +} + +TEST(EntityOutlineIndex, HugeZoomDoesNotRepeatClampedBorderCells) +{ + CEntityRegions Regions; + Regions.m_Width = Regions.m_Height = 3; + Regions.m_vTypes = {CEntityRegions::SOLID, CEntityRegions::SOLID, CEntityRegions::SOLID, + CEntityRegions::NONE, CEntityRegions::NONE, CEntityRegions::NONE, + CEntityRegions::SOLID, CEntityRegions::SOLID, CEntityRegions::SOLID}; + CEntityOutlineIndex Index; + Index.Build(Regions); + int Count = 0, BorderCount = 0; + Index.Visit(-100000, -100000, 100000, 100000, [&](const auto &Cell, vec2 Position, vec2 Scale) { + ++Count; + if(Cell.m_Position.x < 0 || Cell.m_Position.x >= 3) + { + ++BorderCount; + EXPECT_GT(Scale.x, 99990); + EXPECT_EQ(Scale.y, 1); + const auto Shape = RoundedTiles::Build(Cell.m_Mask, 16, 2, 4, Cell.m_Blockers, Cell.m_Transition); + ASSERT_EQ(Shape.m_vQuads.size(), 1u); + for(const auto &Segment : Shape.m_vContour) + EXPECT_EQ(Segment.m_A.y, Segment.m_B.y); + EXPECT_EQ(Position.y, Cell.m_Position.y * 32.0f); + } + }); + EXPECT_EQ(Count, 10); + EXPECT_EQ(BorderCount, 4); +} + +TEST(EntityOutlineIndex, BorderRunCoverageMatchesScalarRendering) +{ + CEntityRegions Regions; + Regions.m_Width = Regions.m_Height = 3; + Regions.m_vTypes = {CEntityRegions::SOLID, CEntityRegions::SOLID, CEntityRegions::SOLID, + CEntityRegions::NONE, CEntityRegions::NONE, CEntityRegions::NONE, + CEntityRegions::SOLID, CEntityRegions::SOLID, CEntityRegions::SOLID}; + CEntityOutlineIndex Index; + Index.Build(Regions); + int Count = 0; + Index.Visit(-20, 0, -10, 3, [&](const auto &Cell, vec2 Position, vec2 Scale) { + ++Count; + EXPECT_EQ(Position.x, -640); + EXPECT_EQ(Scale, vec2(10, 1)); + const auto Shape = RoundedTiles::Build(Cell.m_Mask, 16, 2, 4, Cell.m_Blockers, Cell.m_Transition); + for(int X = -20; X < -10; ++X) + for(float Y : {0.5f, 2.5f, 16.0f, 29.5f, 31.5f}) + { + const vec2 Point = vec2(X * 32.0f + 16, Cell.m_Position.y * 32.0f + Y); + const vec2 Local = (Point - Position) / Scale; + EXPECT_EQ(RoundedTiles::BandDistance(Shape, Local, 2) <= 0, + RoundedTiles::BandDistance(Shape, vec2(16, Y), 2) <= 0); + } + }); + EXPECT_EQ(Count, 2); +}