feat(render): 实现中文文本渲染与悬停提示功能- 在 Mesh2D 中增加悬停状态支持,允许显示红色边框和名称标签

- 添加 splitLines 方法支持文本自动换行显示
- 重构 TextRenderer 以支持 ASCII 和中文字符混合渲染
- 增加 getTextWidth 方法用于计算文本实际渲染宽度
- 修复 RenderSystem 中字体加载方法命名一致性问题- 调整 ModelRenderPanel 中坐标转换逻辑,确保拾取准确性
- 移除冗余的 Matrix3fUtils 引用,优化包导入结构- 完善 Mesh2D 绘制流程中的程序状态管理和纹理绑定操作- 为 Mesh2D 和 ModelPart 建立双向关联,便于获取模型部件名称
- 修改摄像机偏移计算方式,提高渲染坐标一致性
This commit is contained in:
tzdwindows 7
2025-10-25 10:08:09 +08:00
parent d2bb534d26
commit 331d836d62
10 changed files with 473 additions and 229 deletions

View File

@@ -195,6 +195,7 @@ public final class ModelRender {
private static final int FONT_BITMAP_HEIGHT = 512;
private static final int FONT_FIRST_CHAR = 32;
private static final int FONT_CHAR_COUNT = 96;
// ================== 摄像机API方法 ==================
/**
@@ -329,15 +330,15 @@ public final class ModelRender {
// 初始化默认字体(可替换为你自己的 TTF 数据)
ByteBuffer fontData = null;
try {
fontData = RenderSystem.loadWindowsFont("Arial.ttf");
fontData = RenderSystem.loadFont("FZYTK.TTF");
} catch (Exception e) {
logger.warn("Failed to load Arial.ttf, trying fallback fonts", e);
// 尝试其他字体
try {
fontData = RenderSystem.loadWindowsFont("arial.ttf");
fontData = RenderSystem.loadFont("arial.ttf");
} catch (Exception e2) {
try {
fontData = RenderSystem.loadWindowsFont("times.ttf");
fontData = RenderSystem.loadFont("times.ttf");
} catch (Exception e3) {
logger.error("All font loading attempts failed");
}
@@ -348,7 +349,7 @@ public final class ModelRender {
defaultTextRenderer = new TextRenderer(FONT_BITMAP_WIDTH, FONT_BITMAP_HEIGHT, FONT_FIRST_CHAR, FONT_CHAR_COUNT);
RenderSystem.checkGLError("TextRenderer constructor");
defaultTextRenderer.initialize(fontData, 32.0f); // 字体像素高度 32
defaultTextRenderer.initialize(fontData, 20.0f);
RenderSystem.checkGLError("defaultTextRenderer initialization");
if (!defaultTextRenderer.isInitialized()) {
@@ -635,17 +636,17 @@ public final class ModelRender {
RenderSystem.checkGLError("after_render_colliders");
}
if (defaultTextRenderer != null) {
String camInfo = String.format("Camera X: %.2f Y: %.2f Zoom: %.2f",
camera.getPosition().x,
camera.getPosition().y,
camera.getZoom());
float x = 10.0f;
float y = viewportHeight - 30.0f;
Vector4f color = new Vector4f(1.0f, 1.0f, 1.0f, 1.0f);
renderText(camInfo, x, y, color);
RenderSystem.checkGLError("renderText");
}
//if (defaultTextRenderer != null) {
// String camInfo = String.format("Camera X: %.2f Y: %.2f Zoom: %.2f",
// camera.getPosition().x,
// camera.getPosition().y,
// camera.getZoom());
// float x = 10.0f;
// float y = viewportHeight - 30.0f;
// Vector4f color = new Vector4f(1.0f, 1.0f, 1.0f, 1.0f);
// renderText(camInfo, x, y, color);
// RenderSystem.checkGLError("renderText");
//}
RenderSystem.checkGLError("render_end");
}
@@ -801,8 +802,12 @@ public final class ModelRender {
if (!mesh.isVisible()) return;
// 如果 mesh 已经被烘焙到世界坐标,则传 identity 矩阵给 shader防止重复变换
Matrix3f matToUse = mesh.isBakedToWorld() ? new Matrix3f().identity() : modelMatrix;
Matrix3f matToUse = mesh.isBakedToWorld() ? new Matrix3f().identity() : new Matrix3f(modelMatrix);
// 手动应用摄像机偏移
Vector2f offset = getCameraOffset();
matToUse.m20(matToUse.m20() - offset.x);
matToUse.m21(matToUse.m21() - offset.y);
// 设置纹理相关的uniform
if (mesh.getTexture() != null) {
mesh.getTexture().bind(0); // 绑定到纹理单元0
@@ -822,6 +827,7 @@ public final class ModelRender {
RenderSystem.checkGLError("renderMesh");
}
// ================== 渲染碰撞箱相关实现 ==================
private static void renderPhysicsColliders(PhysicsSystem physics) {
@@ -984,9 +990,14 @@ public final class ModelRender {
setUniformVec4Internal(sp, "uColor", new Vector4f(1,1,1,1));
}
public static TextRenderer getTextRenderer() {
return defaultTextRenderer;
}
// ================== 工具 ==================
private static Matrix3f buildOrthoProjection(int width, int height) {
Matrix3f m = new Matrix3f();
// 这个投影把屏幕像素坐标x in [0,width], y in [0,height])映射到 NDC [-1,1]x[1,-1]
m.set(
2.0f / width, 0.0f, -1.0f,
0.0f, -2.0f / height, 1.0f,
@@ -995,19 +1006,20 @@ public final class ModelRender {
return m;
}
/**
* 渲染文字
* @param text 文字内容
* @param x 世界坐标 X
* @param y 世界坐标 Y
* @param y 世界坐标 Y ,反转的
* @param color RGBA 颜色
*/
public static void renderText(String text, float x, float y, Vector4f color) {
if (!initialized || defaultTextRenderer == null) return;
RenderSystem.assertOnRenderThread();
Vector2f offset = getCameraOffset();
float px = x + offset.x;
float py = y + offset.y;
float px = x - offset.x;
float py = y - offset.y;
defaultTextRenderer.renderText(text, px, py, color);
}

View File

@@ -1,6 +1,7 @@
package com.chuangzhou.vivid2D.render.systems;
package com.chuangzhou.vivid2D.render;
import com.chuangzhou.vivid2D.render.model.util.BoundingBox;
import com.chuangzhou.vivid2D.render.systems.RenderSystem;
import com.chuangzhou.vivid2D.render.systems.buffer.BufferBuilder;
import com.chuangzhou.vivid2D.render.systems.buffer.Tesselator;
import org.joml.Vector2f;

View File

@@ -5,7 +5,6 @@ import com.chuangzhou.vivid2D.render.systems.buffer.BufferBuilder;
import com.chuangzhou.vivid2D.render.systems.buffer.Tesselator;
import com.chuangzhou.vivid2D.render.systems.sources.ShaderManagement;
import com.chuangzhou.vivid2D.render.systems.sources.ShaderProgram;
import org.joml.Vector2f;
import org.joml.Vector4f;
import org.lwjgl.opengl.*;
import org.lwjgl.stb.STBTTAlignedQuad;
@@ -19,30 +18,28 @@ import java.nio.ByteBuffer;
import static org.lwjgl.stb.STBTruetype.*;
/**
* OpenGL 文渲染器实例类
* 支持多字体、多实例管理,每个实例维护独立的字符数据与纹理
*
* 支持 ASCII + 中文的 OpenGL 文渲染器
* @author tzdwindows 7
*/
public final class TextRenderer {
private static final Logger logger = LoggerFactory.getLogger(TextRenderer.class);
private final int bitmapWidth;
private final int bitmapHeight;
private final int firstChar;
private final int charCount;
private STBTTBakedChar.Buffer charData;
private int fontTextureId;
private STBTTBakedChar.Buffer asciiCharData;
private STBTTBakedChar.Buffer chineseCharData;
private int asciiTextureId;
private int chineseTextureId;
private boolean initialized = false;
/**
* 构造函数
*
* @param bitmapWidth 字符纹理宽度
* @param bitmapHeight 字符纹理高度
* @param firstChar 字符起始码
* @param charCount 字符数量
*/
// 中文字符起始编码(选择一个不冲突的范围)
private static final int CHINESE_FIRST_CHAR = 0x4E00; // CJK Unified Ideographs 常用汉字起始范围
private static final int CHINESE_CHAR_COUNT = 20000;
public TextRenderer(int bitmapWidth, int bitmapHeight, int firstChar, int charCount) {
this.bitmapWidth = bitmapWidth;
this.bitmapHeight = bitmapHeight;
@@ -52,108 +49,130 @@ public final class TextRenderer {
/**
* 初始化字体渲染器
*
* @param fontData TTF 字体文件内容
* @param fontHeight 字体像素高度
*/
public void initialize(ByteBuffer fontData, float fontHeight) {
if (initialized) return;
if (initialized) {
logger.warn("TextRenderer already initialized");
return;
}
if (fontData == null || fontData.capacity() == 0 || fontHeight <= 0) {
logger.error("Invalid font data or font height");
return;
}
ShaderProgram shader = ShaderManagement.getShaderProgram("TextShader");
if (shader == null) {
logger.error("TextShader not found");
return;
}
shader.use();
// 验证输入参数
if (fontData == null || fontData.capacity() == 0) {
logger.error("Invalid font data provided to TextRenderer");
return;
}
if (fontHeight <= 0) {
logger.error("Invalid font height: {}", fontHeight);
return;
}
if (bitmapWidth <= 0 || bitmapHeight <= 0) {
logger.error("Invalid bitmap dimensions: {}x{}", bitmapWidth, bitmapHeight);
return;
}
try {
charData = STBTTBakedChar.malloc(charCount);
// 分配位图内存
int bitmapSize = bitmapWidth * bitmapHeight;
if (bitmapSize <= 0) {
logger.error("Invalid bitmap size: {}", bitmapSize);
// 烘焙 ASCII
asciiCharData = STBTTBakedChar.malloc(charCount);
ByteBuffer asciiBitmap = ByteBuffer.allocateDirect(bitmapWidth * bitmapHeight);
int asciiRes = stbtt_BakeFontBitmap(fontData, fontHeight, asciiBitmap,
bitmapWidth, bitmapHeight, firstChar, asciiCharData);
if (asciiRes <= 0) {
logger.error("ASCII font bake failed, result: {}", asciiRes);
cleanup();
return;
}
asciiTextureId = createTextureFromBitmap(bitmapWidth, bitmapHeight, asciiBitmap);
if (asciiTextureId == 0) {
logger.error("Failed to create ASCII texture");
cleanup();
return;
}
ByteBuffer bitmap = ByteBuffer.allocateDirect(bitmapSize);
// 烘焙中文 - 使用更大的纹理和正确的字符范围
int chineseTexSize = 4096; // 中文字符需要更大的纹理
// 分配足够的空间来存储 CHINESE_CHAR_COUNT 个字符的数据
chineseCharData = STBTTBakedChar.malloc(CHINESE_CHAR_COUNT);
ByteBuffer chineseBitmap = ByteBuffer.allocateDirect(chineseTexSize * chineseTexSize);
// 烘焙字体位图
int result = stbtt_BakeFontBitmap(fontData, fontHeight, bitmap, bitmapWidth, bitmapHeight, firstChar, charData);
if (result <= 0) {
logger.error("stbtt_BakeFontBitmap failed with result: {}", result);
charData.free();
// 关键:烘焙从 CHINESE_FIRST_CHAR 开始的 CHINESE_CHAR_COUNT 个连续字符
int chineseRes = stbtt_BakeFontBitmap(fontData, fontHeight, chineseBitmap,
chineseTexSize, chineseTexSize, CHINESE_FIRST_CHAR, chineseCharData);
if (chineseRes <= 0) {
logger.error("Chinese font bake failed, result: {}", chineseRes);
cleanup();
return;
}
// 创建纹理
fontTextureId = createTextureFromBitmap(bitmapWidth, bitmapHeight, bitmap);
if (fontTextureId == 0) {
logger.error("Failed to create font texture");
charData.free();
chineseTextureId = createTextureFromBitmap(chineseTexSize, chineseTexSize, chineseBitmap);
if (chineseTextureId == 0) {
logger.error("Failed to create Chinese texture");
cleanup();
return;
}
initialized = true;
logger.debug("TextRenderer initialized successfully with texture ID: {}", fontTextureId);
logger.debug("TextRenderer initialized, ASCII tex={}, Chinese tex={}", asciiTextureId, chineseTextureId);
} catch (Exception e) {
logger.error("Exception during TextRenderer initialization: {}", e.getMessage());
if (charData != null) {
charData.free();
charData = null;
}
logger.error("Exception during TextRenderer init: {}", e.getMessage(), e);
cleanup();
} finally {
shader.stop();
}
shader.stop();
}
private int createTextureFromBitmap(int width, int height, ByteBuffer pixels) {
RenderSystem.assertOnRenderThread();
int textureId = RenderSystem.genTextures();
RenderSystem.bindTexture(textureId);
// 使用更兼容的纹理格式
RenderSystem.texImage2D(GL11.GL_TEXTURE_2D, 0, GL11.GL_ALPHA,
width, height, 0, GL11.GL_ALPHA, GL11.GL_UNSIGNED_BYTE, pixels);
RenderSystem.setTextureMinFilter(GL11.GL_LINEAR);
RenderSystem.setTextureMagFilter(GL11.GL_LINEAR);
RenderSystem.setTextureWrapS(GL12.GL_CLAMP_TO_EDGE);
RenderSystem.setTextureWrapT(GL12.GL_CLAMP_TO_EDGE);
RenderSystem.bindTexture(0);
return textureId;
}
/**
* 渲染文字(使用 RenderSystem 封装,不使用 glBegin/glEnd
*
* @param text 要显示的文字
* @param x 世界坐标 X
* @param y 世界坐标 Y
* @param color 文字颜色
* 渲染文字
*/
public void renderText(String text, float x, float y, Vector4f color) {
renderText(text, x, y, color, 1.0f);
}
/**
* 获取一行文字的宽度(单位:像素)
*/
public float getTextWidth(String text) {
return getTextWidth(text, 1.0f);
}
/**
* 获取一行文字的宽度(带缩放)
*/
public float getTextWidth(String text, float scale) {
if (!initialized || text == null || text.isEmpty()) return 0f;
float width = 0f;
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
if (c >= firstChar && c < firstChar + charCount) {
STBTTBakedChar bakedChar = asciiCharData.get(c - firstChar);
width += bakedChar.xadvance() * scale;
} else {
// 修复中文索引逻辑:检查字符是否在烘焙的连续范围内
if (c >= CHINESE_FIRST_CHAR && c < CHINESE_FIRST_CHAR + CHINESE_CHAR_COUNT) {
int idx = c - CHINESE_FIRST_CHAR; // 关键:使用 Unicode 差值作为索引
STBTTBakedChar bakedChar = chineseCharData.get(idx);
width += bakedChar.xadvance() * scale;
} else {
// 对于未找到的字符,使用空格宽度
width += 0.5f * scale; // 估计值
}
}
}
return width;
}
public void renderText(String text, float x, float y, Vector4f color, float scale) {
if (!initialized || text == null || text.isEmpty()) return;
if (scale <= 0f) scale = 1.0f;
RenderSystem.assertOnRenderThread();
// 保存当前状态
RenderSystem.pushState();
try {
// 检查文本着色器是否存在,如果不存在则创建默认的
ShaderProgram shader = ShaderManagement.getShaderProgram("TextShader");
if (shader == null) {
logger.error("TextShader not found");
return;
}
shader.use();
ShaderManagement.setUniformVec4(shader, "uColor", color);
ShaderManagement.setUniformInt(shader, "uTexture", 0);
@@ -162,76 +181,143 @@ public final class TextRenderer {
RenderSystem.blendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA);
RenderSystem.disableDepthTest();
RenderSystem.activeTexture(GL13.GL_TEXTURE0);
RenderSystem.bindTexture(fontTextureId);
Vector2f offset = ModelRender.getCameraOffset();
float px = x + offset.x;
float py = y + offset.y;
try (MemoryStack stack = MemoryStack.stackPush()) {
STBTTAlignedQuad q = STBTTAlignedQuad.mallocStack(stack);
float[] xpos = {px};
float[] ypos = {py};
STBTTAlignedQuad q = STBTTAlignedQuad.malloc(stack);
float[] xpos = {x};
float[] ypos = {y};
Tesselator tesselator = Tesselator.getInstance();
BufferBuilder builder = tesselator.getBuilder();
Tesselator t = Tesselator.getInstance();
BufferBuilder builder = t.getBuilder();
// 计算估计的顶点数量每个字符6个顶点2个三角形
int estimatedVertexCount = text.length() * 6;
// 修复begin方法需要2个参数
builder.begin(RenderSystem.DRAW_TRIANGLES, estimatedVertexCount);
// 按字符类型分组渲染以减少纹理切换
int currentTexture = -1;
boolean batchStarted = false;
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
if (c < firstChar || c >= firstChar + charCount) continue;
int targetTexture;
STBTTBakedChar.Buffer charBuffer;
int texWidth, texHeight;
stbtt_GetBakedQuad(charData, bitmapWidth, bitmapHeight, c - firstChar, xpos, ypos, q, true);
if (c >= firstChar && c < firstChar + charCount) {
targetTexture = asciiTextureId;
charBuffer = asciiCharData;
texWidth = bitmapWidth;
texHeight = bitmapHeight;
stbtt_GetBakedQuad(charBuffer, texWidth, texHeight, c - firstChar, xpos, ypos, q, true);
} else {
// 修复中文索引逻辑:检查字符是否在烘焙的连续范围内
if (c >= CHINESE_FIRST_CHAR && c < CHINESE_FIRST_CHAR + CHINESE_CHAR_COUNT) {
targetTexture = chineseTextureId;
charBuffer = chineseCharData;
texWidth = 4096;
texHeight = 4096;
// 关键修复:索引是字符的 Unicode 减去起始 Unicode
int idx = c - CHINESE_FIRST_CHAR;
stbtt_GetBakedQuad(charBuffer, texWidth, texHeight, idx, xpos, ypos, q, true);
} else {
continue; // 跳过不支持的字符
}
}
// 使用两个三角形组成一个四边形
// 第一个三角形
builder.vertex(q.x0(), q.y0(), q.s0(), q.t0());
builder.vertex(q.x1(), q.y0(), q.s1(), q.t0());
builder.vertex(q.x0(), q.y1(), q.s0(), q.t1());
// 如果纹理改变,结束当前批次
if (targetTexture != currentTexture) {
if (batchStarted) {
t.end();
batchStarted = false;
}
RenderSystem.bindTexture(targetTexture);
currentTexture = targetTexture;
}
// 第二个三角形
builder.vertex(q.x1(), q.y0(), q.s1(), q.t0());
builder.vertex(q.x1(), q.y1(), q.s1(), q.t1());
builder.vertex(q.x0(), q.y1(), q.s0(), q.t1());
// 开始新批次(如果需要)
if (!batchStarted) {
builder.begin(RenderSystem.DRAW_TRIANGLES, (text.length() - i) * 6);
batchStarted = true;
}
// 应用缩放并计算顶点
float sx0 = x + (q.x0() - x) * scale;
float sx1 = x + (q.x1() - x) * scale;
float sy0 = y + (q.y0() - y) * scale;
float sy1 = y + (q.y1() - y) * scale;
builder.vertex(sx0, sy0, q.s0(), q.t0());
builder.vertex(sx1, sy0, q.s1(), q.t0());
builder.vertex(sx0, sy1, q.s0(), q.t1());
builder.vertex(sx1, sy0, q.s1(), q.t0());
builder.vertex(sx1, sy1, q.s1(), q.t1());
builder.vertex(sx0, sy1, q.s0(), q.t1());
}
tesselator.end();
// 结束最后一个批次
if (batchStarted) {
t.end();
}
}
RenderSystem.checkGLError("renderText");
} catch (Exception e) {
logger.error("Error rendering text: {}", e.getMessage(), e);
} finally {
// 恢复之前的状态
RenderSystem.popState();
}
}
/**
* 清理字体资源
*/
public void cleanup() {
if (fontTextureId != 0) {
RenderSystem.deleteTextures(fontTextureId);
fontTextureId = 0;
private int createTextureFromBitmap(int width, int height, ByteBuffer pixels) {
RenderSystem.assertOnRenderThread();
try {
int textureId = RenderSystem.genTextures();
RenderSystem.bindTexture(textureId);
RenderSystem.pixelStore(GL11.GL_UNPACK_ALIGNMENT, 1);
RenderSystem.texImage2D(GL11.GL_TEXTURE_2D, 0, GL30.GL_R8, width, height, 0,
GL11.GL_RED, GL11.GL_UNSIGNED_BYTE, pixels);
RenderSystem.setTextureMinFilter(GL11.GL_LINEAR);
RenderSystem.setTextureMagFilter(GL11.GL_LINEAR);
RenderSystem.setTextureWrapS(GL12.GL_CLAMP_TO_EDGE);
RenderSystem.setTextureWrapT(GL12.GL_CLAMP_TO_EDGE);
// 设置纹理swizzle以便单通道纹理在着色器中显示为白色
RenderSystem.texParameteri(GL11.GL_TEXTURE_2D, GL33.GL_TEXTURE_SWIZZLE_R, GL11.GL_RED);
RenderSystem.texParameteri(GL11.GL_TEXTURE_2D, GL33.GL_TEXTURE_SWIZZLE_G, GL11.GL_RED);
RenderSystem.texParameteri(GL11.GL_TEXTURE_2D, GL33.GL_TEXTURE_SWIZZLE_B, GL11.GL_RED);
RenderSystem.texParameteri(GL11.GL_TEXTURE_2D, GL33.GL_TEXTURE_SWIZZLE_A, GL11.GL_RED);
RenderSystem.pixelStore(GL11.GL_UNPACK_ALIGNMENT, 4);
RenderSystem.bindTexture(0);
return textureId;
} catch (Exception e) {
logger.error("Failed to create texture from bitmap: {}", e.getMessage(), e);
return 0;
}
if (charData != null) {
charData.free();
charData = null;
}
public void cleanup() {
RenderSystem.assertOnRenderThread();
if (asciiTextureId != 0) {
RenderSystem.deleteTextures(asciiTextureId);
asciiTextureId = 0;
}
if (chineseTextureId != 0) {
RenderSystem.deleteTextures(chineseTextureId);
chineseTextureId = 0;
}
if (asciiCharData != null) {
asciiCharData.free();
asciiCharData = null;
}
if (chineseCharData != null) {
chineseCharData.free();
chineseCharData = null;
}
initialized = false;
logger.debug("TextRenderer cleaned up");
}
public boolean isInitialized() {
return initialized;
}
public int getFontTextureId() {
return fontTextureId;
}
}
public boolean isInitialized() { return initialized; }
public int getAsciiTextureId() { return asciiTextureId; }
public int getChineseTextureId() { return chineseTextureId; }
}

View File

@@ -8,7 +8,6 @@ import com.chuangzhou.vivid2D.render.model.ModelPart;
import com.chuangzhou.vivid2D.render.model.util.BoundingBox;
import com.chuangzhou.vivid2D.render.model.util.Mesh2D;
import com.chuangzhou.vivid2D.render.systems.Camera;
import com.chuangzhou.vivid2D.render.systems.Matrix3fUtils;
import com.chuangzhou.vivid2D.render.systems.RenderSystem;
import org.joml.Matrix3f;
import org.joml.Vector2f;
@@ -1014,10 +1013,6 @@ public class ModelRenderPanel extends JPanel {
logger.info("按住Ctrl/Shift点击空白区域保持当前选择状态 - Ctrl: {}, Shift: {}, 当前选中数量: {}",
e.isControlDown(), e.isShiftDown(), selectedMeshes.size());
currentDragMode = DragMode.NONE;
} else {
currentDragMode = DragMode.NONE;
logger.info("点击空白区域,保持当前选择状态 - 当前选中数量: {}, 点击位置: ({}, {})",
selectedMeshes.size(), modelX, modelY);
}
}
}
@@ -1228,11 +1223,11 @@ public class ModelRenderPanel extends JPanel {
Vector2f camOffset = ModelRender.getCameraOffset();
// 应用偏移,将 model 坐标转换到相对于摄像机的坐标系
float checkX = modelX - camOffset.x;
float checkY = modelY - camOffset.y;
float checkX = modelX;
float checkY = modelY;
// 将中心点也转换到相同坐标系
center = new Vector2f(center).sub(camOffset);
//center = new Vector2f(center).sub(camOffset);
float scaleFactor = calculateScaleFactor();
float borderThickness = BORDER_THICKNESS / scaleFactor;
@@ -1368,9 +1363,9 @@ public class ModelRenderPanel extends JPanel {
try {
Camera camera = ModelRender.getCamera();
float zoom = camera.getZoom();
// 计算世界坐标的移动量(反向移动)
float worldDeltaX = -deltaX / zoom;
float worldDeltaY = deltaY / zoom; // AWT/Swing 的 Y 轴与 OpenGL 相反
float worldDeltaY = -deltaY / zoom;
// 应用摄像机移动
camera.move(worldDeltaX, worldDeltaY);
@@ -1712,6 +1707,11 @@ public class ModelRenderPanel extends JPanel {
// 检测点击的网格
Mesh2D clickedMesh = findMeshAtPosition(modelX, modelY);
if (clickedMesh == null && !e.isControlDown() && !e.isShiftDown()) {
clearSelectedMeshes();
logger.debug("点击空白处,取消所有选择");
}
// 触发点击事件
for (ModelClickListener listener : clickListeners) {
try {
@@ -1752,11 +1752,17 @@ public class ModelRenderPanel extends JPanel {
// 检测悬停的网格
Mesh2D newHoveredMesh = findMeshAtPosition(modelX, modelY);
// 更新悬停状态
// 如果悬停网格发生变化,更新状态
if (newHoveredMesh != hoveredMesh) {
hoveredMesh = newHoveredMesh;
if (hoveredMesh != null) {
hoveredMesh.setSuspension(false);
}
hoveredMesh = newHoveredMesh;
if (hoveredMesh != null) {
hoveredMesh.setSuspension(true);
}
// 触发悬停事件
for (ModelClickListener listener : clickListeners) {
try {
listener.onModelHover(newHoveredMesh, modelX, modelY, screenX, screenY);
@@ -1766,7 +1772,6 @@ public class ModelRenderPanel extends JPanel {
}
}
// 更新鼠标手势
updateCursorForHoverState(modelX, modelY, newHoveredMesh);
} catch (Exception ex) {
@@ -1854,23 +1859,16 @@ public class ModelRenderPanel extends JPanel {
float glY = (float) screenY * this.height / getHeight();
// 2. 转换为归一化设备坐标 (NDC)
// NDC 范围 [-1, 1]。GL 坐标 (0, 0) -> NDC (-1, 1) [左上角]
// 这里的 NDC 转换是基于 GL 上下文的尺寸 (this.width, this.height)
float ndcX = (2.0f * glX) / this.width - 1.0f;
// AWT/Swing 的 Y 轴向下OpenGL 的 Y 轴向上,所以需要翻转 Y 轴
float ndcY = 1.0f - (2.0f * glY) / this.height;
float ndcY = 1.0f - (2.0f * glY) / this.height; // 翻转 Y 轴
// 3. 逆投影变换
Camera camera = ModelRender.getCamera();
float zoom = camera.getZoom();
Vector2f pos = camera.getPosition();
// 3. 获取摄像机偏移
Vector2f camOffset = ModelRender.getCameraOffset(); // 这里替换原来的 camera.getPosition()
float zoom = ModelRender.getCamera().getZoom();
// 逆变换公式
// modelX = (ndcX / (2.0f / this.width)) / zoom + pos.x
// modelY = (ndcY / (-2.0f / this.height)) / zoom + pos.y
float modelX = (ndcX * this.width / (2.0f * zoom)) + pos.x;
float modelY = (ndcY * this.height / (-2.0f * zoom)) + pos.y;
// 4. 逆变换公式
float modelX = (ndcX * this.width / (2.0f * zoom)) + camOffset.x;
float modelY = (ndcY * this.height / (-2.0f * zoom)) + camOffset.y;
return new float[]{modelX, modelY};
}
@@ -1890,9 +1888,8 @@ public class ModelRenderPanel extends JPanel {
// 获取摄像机偏移
Vector2f camOffset = ModelRender.getCameraOffset();
// 将输入坐标调整到相对于摄像机的坐标系
float checkX = modelX - camOffset.x;
float checkY = modelY - camOffset.y;
float checkX = modelX;
float checkY = modelY;
java.util.List<ModelPart> parts = model.getParts();
if (parts == null || parts.isEmpty()) {
@@ -1935,6 +1932,7 @@ public class ModelRenderPanel extends JPanel {
}
/**
* 获取模型的边界框
*/

View File

@@ -1060,42 +1060,39 @@ public class ModelPart {
public void addMesh(Mesh2D mesh) {
if (mesh == null) return;
// 创建独立副本,避免多个 Part 共享同一 Mesh 实例导致数据冲突
Mesh2D m = mesh.copy();
// 确保拷贝保留原始的纹理引用copy() 已处理)
m.setTexture(mesh.getTexture());
//mesh.setTexture(mesh.getTexture());
mesh.setModelPart(this);
// 确保本节点的 worldTransform 是最新的
recomputeWorldTransformRecursive();
// 保存拷贝的原始(局部)顶点供后续重算 world 顶点使用
float[] originalVertices = m.getVertices().clone();
m.setOriginalVertices(originalVertices);
float[] originalVertices = mesh.getVertices().clone();
mesh.setOriginalVertices(originalVertices);
// 把 originalPivot 保存在 mesh 中setMeshData 已经初始化 originalPivot
// 将每个顶点从本地空间变换到世界空间(烘焙到 world
int vc = m.getVertexCount();
int vc = mesh.getVertexCount();
for (int i = 0; i < vc; i++) {
Vector2f local = new Vector2f(originalVertices[i * 2], originalVertices[i * 2 + 1]);
Vector2f worldPt = Matrix3fUtils.transformPoint(this.worldTransform, local);
m.setVertex(i, worldPt.x, worldPt.y);
mesh.setVertex(i, worldPt.x, worldPt.y);
}
// 同步 originalPivot -> world pivot如果 originalPivot 有意义)
try {
Vector2f origPivot = m.getOriginalPivot();
Vector2f origPivot = mesh.getOriginalPivot();
Vector2f worldPivot = Matrix3fUtils.transformPoint(this.worldTransform, origPivot);
m.setPivot(worldPivot.x, worldPivot.y);
mesh.setPivot(worldPivot.x, worldPivot.y);
} catch (Exception ignored) { }
// 标记为已烘焙到世界坐标(语义上明确),并确保 bounds/dirty 状态被正确刷新
m.setBakedToWorld(true);
mesh.setBakedToWorld(true);
// 确保 GPU 数据在下一次绘制时会被上传(如果当前在渲染线程,也可以直接 uploadToGPU
m.markDirty();
mesh.markDirty();
// 将拷贝加入到本部件
meshes.add(m);
meshes.add(mesh);
boundsDirty = true;
}

View File

@@ -1,10 +1,15 @@
package com.chuangzhou.vivid2D.render.model.util;
import com.chuangzhou.vivid2D.render.systems.MultiSelectionBoxRenderer;
import com.chuangzhou.vivid2D.render.ModelRender;
import com.chuangzhou.vivid2D.render.MultiSelectionBoxRenderer;
import com.chuangzhou.vivid2D.render.TextRenderer;
import com.chuangzhou.vivid2D.render.model.ModelPart;
import com.chuangzhou.vivid2D.render.systems.RenderSystem;
import com.chuangzhou.vivid2D.render.systems.buffer.BufferBuilder;
import com.chuangzhou.vivid2D.render.systems.buffer.Tesselator;
import com.chuangzhou.vivid2D.render.systems.sources.ShaderManagement;
import com.chuangzhou.vivid2D.render.systems.sources.ShaderProgram;
import org.joml.Matrix3f;
import org.joml.Vector2f;
import java.nio.FloatBuffer;
@@ -14,10 +19,7 @@ import java.util.List;
import java.util.Objects;
import org.joml.Vector4f;
import org.lwjgl.opengl.GL11;
import org.lwjgl.opengl.GL15;
import org.lwjgl.opengl.GL20;
import org.lwjgl.opengl.GL30;
import org.lwjgl.opengl.*;
import org.lwjgl.system.MemoryUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -36,6 +38,7 @@ public class Mesh2D {
private float[] uvs; // UV坐标 [u0, v0, u1, v1, ...]
private int[] indices; // 索引数据
private float[] originalVertices; // 原始顶点数据(用于变形恢复)
private ModelPart modelPart;
// ==================== 渲染属性 ====================
private Texture texture;
@@ -55,6 +58,7 @@ public class Mesh2D {
private volatile boolean selected = false;
private Vector2f pivot = new Vector2f(0, 0);
private Vector2f originalPivot = new Vector2f(0, 0);
private boolean isSuspension = false;
// ==================== 多选支持 ====================
private final List<Mesh2D> multiSelectedParts = new ArrayList<>();
@@ -117,6 +121,10 @@ public class Mesh2D {
markDirty();
}
public void setModelPart(ModelPart modelPart) {
this.modelPart = modelPart;
}
/**
* 设置中心点
*/
@@ -618,6 +626,13 @@ public class Mesh2D {
this.multiSelectionDirty = true; // 新增:标记多选边界框需要更新
}
/**
* 设置当前的“悬停状态”
*/
public void setSuspension(boolean suspension) {
isSuspension = suspension;
}
/**
* 清除脏标记
*/
@@ -693,44 +708,71 @@ public class Mesh2D {
/**
* 绘制网格(会在第一次绘制时自动上传到 GPU
*/
public void draw(int shaderProgram, org.joml.Matrix3f modelMatrix) {
public void draw(int shaderProgram, Matrix3f modelMatrix) {
if (!visible) return;
if (indices == null || indices.length == 0) return;
// 如果数据脏,强制删除旧 GPU 对象并重新上传
if (dirty) {
deleteGPU();
uploadToGPU();
}
if (!uploaded) {
uploadToGPU();
}
// 1. 绘制网格
// 保存当前 program必要时恢复
int prevProgram = RenderSystem.getCurrentProgram();
boolean switchedProgram = false;
if (shaderProgram != 0 && prevProgram != shaderProgram) {
RenderSystem.useProgram(shaderProgram);
switchedProgram = true;
}
// 确保纹理绑定到纹理单元0并通知 shader 使用纹理单元0
RenderSystem.activeTexture(GL13.GL_TEXTURE0);
if (texture != null) {
texture.bind();
} else {
RenderSystem.bindTexture(0);
}
if (shaderProgram != 0) {
int texLoc = RenderSystem.getUniformLocation(shaderProgram, "uTexture");
if (texLoc != -1) RenderSystem.uniform1i(texLoc, 0);
}
if (shaderProgram != 0) {
int loc = RenderSystem.getUniformLocation(shaderProgram, "uModelMatrix");
if (loc == -1) loc = RenderSystem.getUniformLocation(shaderProgram, "uModel");
if (loc != -1) RenderSystem.uniformMatrix3(loc, modelMatrix);
}
// 绑定 VAO 并绘制
RenderSystem.glBindVertexArray(() -> vaoId);
RenderSystem.useProgram(shaderProgram);
int loc = RenderSystem.getUniformLocation(shaderProgram, "uModelMatrix");
if (loc == -1) {
loc = RenderSystem.getUniformLocation(shaderProgram, "uModel");
}
if (loc != -1) {
RenderSystem.uniformMatrix3(loc, modelMatrix);
}
RenderSystem.drawElements(RenderSystem.DRAW_TRIANGLES, indexCount,
RenderSystem.GL_UNSIGNED_INT, 0);
// 2. 解绑 VAO 和纹理,确保 overlay 绘制不受影响
RenderSystem.glBindVertexArray(() -> 0);
// 解绑纹理恢复到单元0的绑定0
if (texture != null) {
texture.unbind();
} else {
RenderSystem.bindTexture(0);
}
// 3. 如果选中,则绘制选中框
// 恢复之前的 program如果我们切换过
if (switchedProgram) {
if (prevProgram != 0) RenderSystem.useProgram(prevProgram);
else RenderSystem.useProgram(0);
}
// 选中框绘制(需要切换到固色 shader
if (selected) {
RenderSystem.enableBlend();
RenderSystem.blendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA);
int currentProgram = RenderSystem.getCurrentProgram();
RenderSystem.pushState();
try {
ShaderProgram solidShader = ShaderManagement.getShaderProgram("Solid Color Shader");
if (solidShader != null && solidShader.programId != 0) {
@@ -751,11 +793,107 @@ public class Mesh2D {
drawSelectBox();
}
} finally {
if (currentProgram != 0) {
RenderSystem.useProgram(currentProgram);
}
RenderSystem.popState();
}
}
if (isSuspension && !selected) {
RenderSystem.pushState();
ShaderProgram solidShader = ShaderManagement.getShaderProgram("Solid Color Shader");
if (solidShader != null && solidShader.programId != 0) {
solidShader.use();
int modelLoc = solidShader.getUniformLocation("uModelMatrix");
if (modelLoc != -1) {
RenderSystem.uniformMatrix3(modelLoc, modelMatrix);
}
int colorLoc = solidShader.getUniformLocation("uColor");
if (colorLoc != -1) {
RenderSystem.uniform4f(colorLoc, 1.0f, 1.0f, 1.0f, 1.0f);
}
}
RenderSystem.enableBlend();
RenderSystem.blendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA);
Tesselator t = Tesselator.getInstance();
BufferBuilder bb = t.getBuilder();
BoundingBox bbox = getBounds();
if (bbox != null && bbox.isValid()) {
bb.begin(GL11.GL_LINE_LOOP, 4);
bb.setColor(new Vector4f(1f, 0f, 0f, 1f));
bb.vertex(bbox.getMinX(), bbox.getMinY(), 0f, 0f);
bb.vertex(bbox.getMaxX(), bbox.getMinY(), 0f, 0f);
bb.vertex(bbox.getMaxX(), bbox.getMaxY(), 0f, 0f);
bb.vertex(bbox.getMinX(), bbox.getMaxY(), 0f, 0f);
t.end();
String hoverText = getName();
float textX = bbox.getMaxX() + 5f;
float textY = bbox.getMaxY();
Vector4f bgColor = new Vector4f(1f, 0f, 0f, 0.8f);
Vector4f fgColor = new Vector4f(1f, 1f, 1f, 1f);
float lineHeight = 18f;
List<String> lines = splitLines(hoverText, 30);
float textHeight = lines.size() * lineHeight;
float textWidth = 0f;
for (String line : lines) {
textWidth = Math.max(textWidth, ModelRender.getTextRenderer().getTextWidth(line));
}
bb.begin(GL11.GL_TRIANGLES, 6);
bb.setColor(bgColor);
bb.vertex(textX, textY, 0f, 0f);
bb.vertex(textX + textWidth, textY, 0f, 0f);
bb.vertex(textX + textWidth, textY + textHeight, 0f, 0f);
bb.vertex(textX + textWidth, textY + textHeight, 0f, 0f);
bb.vertex(textX, textY + textHeight, 0f, 0f);
bb.vertex(textX, textY, 0f, 0f);
t.end();
for (int i = 0; i < lines.size(); i++) {
String line = lines.get(i);
ModelRender.renderText(line, textX, textY + (i + 1) * lineHeight - 5, fgColor);
}
}
RenderSystem.popState();
}
}
public static List<String> splitLines(String text, int maxCharsPerLine) {
List<String> lines = new ArrayList<>();
StringBuilder line = new StringBuilder();
for (String word : text.split(" ")) {
if (line.length() + word.length() + 1 > maxCharsPerLine) {
if (!line.isEmpty()) {
lines.add(line.toString());
line = new StringBuilder();
}
if (word.length() > maxCharsPerLine) {
int start = 0;
while (start < word.length()) {
int end = Math.min(start + maxCharsPerLine, word.length());
lines.add(word.substring(start, end));
start = end;
}
continue;
}
}
if (!line.isEmpty()) {
line.append(" ");
}
line.append(word);
}
if (!line.isEmpty()) {
lines.add(line.toString());
}
return lines;
}
private void drawSelectBox() {
@@ -1281,6 +1419,9 @@ public class Mesh2D {
// ==================== Getter/Setter ====================
public String getName() {
if (modelPart != null){
return modelPart.getName();
}
return name;
}

View File

@@ -9,7 +9,7 @@ import org.joml.Vector2f;
public class Camera {
private final Vector2f position = new Vector2f(0.0f, 0.0f);
private float zoom = 1.0f;
private float zPosition = 0.0f; // Z轴位置影响深度
private float zPosition = 0.0f;
private boolean enabled = true;
public Camera() {}
@@ -46,6 +46,9 @@ public class Camera {
this.enabled = enabled;
}
/**
* 获取摄像机是否启用
*/
public boolean isEnabled() {
return enabled;
}

View File

@@ -829,7 +829,7 @@ public final class RenderSystem {
}
}
public static ByteBuffer loadWindowsFont(String fontFileName) throws IOException {
public static ByteBuffer loadFont(String fontFileName) throws IOException {
Path path = Path.of("C:/Windows/Fonts/" + fontFileName);
try (FileChannel fc = FileChannel.open(path, StandardOpenOption.READ)) {
ByteBuffer buffer = ByteBuffer.allocateDirect((int) fc.size());

View File

@@ -203,6 +203,7 @@ public class BufferBuilder {
int vao = RenderSystem.glGenVertexArrays();
int vbo = RenderSystem.glGenBuffers();
// 使用 RenderSystem 要求的 Supplier/IntSupplier 风格来绑定(修复类型不匹配编译错误)
RenderSystem.glBindVertexArray(() -> vao);
RenderSystem.glBindBuffer(RenderSystem.GL_ARRAY_BUFFER, () -> vbo);
RenderSystem.glBufferData(RenderSystem.GL_ARRAY_BUFFER, fb, RenderSystem.GL_DYNAMIC_DRAW);
@@ -217,6 +218,8 @@ public class BufferBuilder {
COMPONENTS_PER_VERTEX * Float.BYTES, 2 * Float.BYTES);
MemoryUtil.memFree(fb);
// 解绑 VBO/VAO同样使用 Supplier/IntSupplier
RenderSystem.glBindBuffer(RenderSystem.GL_ARRAY_BUFFER, () -> 0);
RenderSystem.glBindVertexArray(() -> 0);

View File

@@ -54,11 +54,14 @@ public class TextShader implements CompleteShader {
layout(location = 0) in vec2 aPosition;
layout(location = 1) in vec2 aTexCoord;
uniform mat3 uProjectionMatrix;
out vec2 vTexCoord;
void main() {
vec3 p = uProjectionMatrix * vec3(aPosition, 1.0);
gl_Position = vec4(p.xy, 0.0, 1.0);
vTexCoord = aTexCoord;
gl_Position = vec4(aPosition.xy, 0.0, 1.0);
}
""";
}