feat(09): per-face UV studs (тайлинг по граням) + настройка размера studs
- Тайлинг studs на кубе через faceUV (per-face) — кружки одного размера на всех гранях, не растягиваются на длинной стороне (баг на брусе 10×1×1). _studsCubeFaceUV считает UV каждой грани по её реальным размерам. - studDensity — плотность кружков (множитель): инспектор «Размер studs» Крупные(0.5)/Средние(1)/Мелкие(2)/Меньше(4). Для пола мелкие, для кирпича крупные. Проброс через data/_studsDims/faceUV/_studsTiling, сериализация, updateInstance(patch.studDensity)→пересоздание меша. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
7ab66fc4c5
commit
f6828aad2c
1
public/dev-studstest.json
Normal file
1
public/dev-studstest.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"version": 1, "scene": {"blocks": [], "models": [], "primitives": [{"id": 1, "type": "cube", "x": 0, "y": 1, "z": 0, "sx": 10, "sy": 1, "sz": 1, "rotationX": 0, "rotationY": 0, "rotationZ": 0, "color": "#e8e8e8", "material": "studs", "canCollide": true, "visible": true, "anchored": true, "mass": 1, "name": "Длинный брус", "folderId": null}, {"id": 2, "type": "cube", "x": 0, "y": 1, "z": 4, "sx": 2, "sy": 2, "sz": 2, "rotationX": 0, "rotationY": 0, "rotationZ": 0, "color": "#3a7aff", "material": "studs", "canCollide": true, "visible": true, "anchored": true, "mass": 1, "name": "Куб 2x2", "folderId": null}, {"id": 3, "type": "cube", "x": 6, "y": 1, "z": 2, "sx": 1, "sy": 1, "sz": 6, "rotationX": 0, "rotationY": 0, "rotationZ": 0, "color": "#e07a30", "material": "studs", "canCollide": true, "visible": true, "anchored": true, "mass": 1, "name": "Брус по Z", "folderId": null}], "userModels": [], "terrain": null, "robloxTerrain": null, "decorations": [], "folders": [], "gui": [], "inventory": [], "spawnPoint": {"x": 0, "y": 1.7, "z": -6}, "playerModelType": "character-a", "worldSize": 100, "floorEnabled": true, "scripts": []}, "editorCamera": null, "settings": {}, "__devName": "Studs-тест"}
|
||||||
@ -317,6 +317,7 @@ const InspectorPanel = ({
|
|||||||
const [localSz, setLocalSz] = useState('');
|
const [localSz, setLocalSz] = useState('');
|
||||||
const [localColor, setLocalColor] = useState('#888888');
|
const [localColor, setLocalColor] = useState('#888888');
|
||||||
const [localMaterial, setLocalMaterial] = useState('matte');
|
const [localMaterial, setLocalMaterial] = useState('matte');
|
||||||
|
const [localStudDensity, setLocalStudDensity] = useState(1); // плотность studs
|
||||||
const [localCanCollide, setLocalCanCollide] = useState(true);
|
const [localCanCollide, setLocalCanCollide] = useState(true);
|
||||||
const [localVisible, setLocalVisible] = useState(true);
|
const [localVisible, setLocalVisible] = useState(true);
|
||||||
const [localAnchored, setLocalAnchored] = useState(true);
|
const [localAnchored, setLocalAnchored] = useState(true);
|
||||||
@ -362,6 +363,7 @@ const InspectorPanel = ({
|
|||||||
setLocalSz((selection.sz || 1).toFixed(2));
|
setLocalSz((selection.sz || 1).toFixed(2));
|
||||||
setLocalColor(selection.color || '#888888');
|
setLocalColor(selection.color || '#888888');
|
||||||
setLocalMaterial(selection.material || 'matte');
|
setLocalMaterial(selection.material || 'matte');
|
||||||
|
setLocalStudDensity(selection.studDensity || 1);
|
||||||
setLocalCanCollide(selection.canCollide !== false);
|
setLocalCanCollide(selection.canCollide !== false);
|
||||||
setLocalVisible(selection.visible !== false);
|
setLocalVisible(selection.visible !== false);
|
||||||
setLocalAnchored(selection.anchored !== false);
|
setLocalAnchored(selection.anchored !== false);
|
||||||
@ -1755,6 +1757,38 @@ const InspectorPanel = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Размер studs — плотность лего-кружков (только для material studs) */}
|
||||||
|
{localMaterial === 'studs' && (
|
||||||
|
<div className={cl.section}>
|
||||||
|
<div className={cl.sectionTitle}><Icon name="grid" size={12} /> Размер studs</div>
|
||||||
|
<div className={cl.row3}>
|
||||||
|
{[
|
||||||
|
{ label: 'Крупные', d: 0.5 },
|
||||||
|
{ label: 'Средние', d: 1 },
|
||||||
|
{ label: 'Мелкие', d: 2 },
|
||||||
|
{ label: 'Меньше', d: 4 },
|
||||||
|
].map(opt => (
|
||||||
|
<button
|
||||||
|
key={opt.d}
|
||||||
|
type="button"
|
||||||
|
className={cl.smallBtn}
|
||||||
|
onClick={() => {
|
||||||
|
setLocalStudDensity(opt.d);
|
||||||
|
onSetPrimitiveProps?.({ studDensity: opt.d });
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
fontWeight: localStudDensity === opt.d ? 700 : 400,
|
||||||
|
background: localStudDensity === opt.d ? 'var(--accent)' : undefined,
|
||||||
|
color: localStudDensity === opt.d ? '#fff' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Текстура — своя картинка на гранях примитива */}
|
{/* Текстура — своя картинка на гранях примитива */}
|
||||||
<div className={cl.section}>
|
<div className={cl.section}>
|
||||||
<div className={cl.sectionTitle}>
|
<div className={cl.sectionTitle}>
|
||||||
|
|||||||
@ -19,7 +19,7 @@
|
|||||||
* При касании игроком обновляет spawnPoint сцены.
|
* При касании игроком обновляет spawnPoint сцены.
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
MeshBuilder, StandardMaterial, Color3, Vector3, PointLight,
|
MeshBuilder, StandardMaterial, Color3, Vector3, Vector4, PointLight,
|
||||||
Mesh, VertexData, Texture, DynamicTexture,
|
Mesh, VertexData, Texture, DynamicTexture,
|
||||||
} from '@babylonjs/core';
|
} from '@babylonjs/core';
|
||||||
import { getPrimitiveType } from './PrimitiveTypes';
|
import { getPrimitiveType } from './PrimitiveTypes';
|
||||||
@ -52,8 +52,10 @@ function _getStudsTextures(scene) {
|
|||||||
* число кружков в самой текстуре (STUDS_GRID).
|
* число кружков в самой текстуре (STUDS_GRID).
|
||||||
* Для куба/плоскости тайлинг прямой; для сферы/цилиндра — приближённый.
|
* Для куба/плоскости тайлинг прямой; для сферы/цилиндра — приближённый.
|
||||||
*/
|
*/
|
||||||
function _studsTiling(type, sx, sy, sz) {
|
function _studsTiling(type, sx, sy, sz, density) {
|
||||||
const f = STUD_UNIT * STUDS_GRID;
|
// density — множитель плотности кружков (1=стандарт, 2=вдвое мельче/чаще).
|
||||||
|
const d = density && density > 0 ? density : 1;
|
||||||
|
const f = (STUD_UNIT * STUDS_GRID) / d;
|
||||||
// По умолчанию (cube/plane/wedge) — горизонтальный размер по U, вертикаль по V.
|
// По умолчанию (cube/plane/wedge) — горизонтальный размер по U, вертикаль по V.
|
||||||
let u = Math.max(sx, sz) / f;
|
let u = Math.max(sx, sz) / f;
|
||||||
let v = sy / f;
|
let v = sy / f;
|
||||||
@ -70,6 +72,31 @@ function _studsTiling(type, sx, sy, sz) {
|
|||||||
}
|
}
|
||||||
return { u: Math.max(0.25, u), v: Math.max(0.25, v) };
|
return { u: Math.max(0.25, u), v: Math.max(0.25, v) };
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* faceUV для куба со studs — КАЖДАЯ грань тайлится по СВОИМ реальным размерам,
|
||||||
|
* чтобы кружки были одного размера на всех гранях (не растягивались на длинных).
|
||||||
|
* Грани CreateBox: 0=front(z-) 1=back(z+) 2=right(x+) 3=left(x-) 4=top(y+) 5=bottom(y-).
|
||||||
|
* front/back → ширина=sx, высота=sy
|
||||||
|
* left/right → ширина=sz, высота=sy
|
||||||
|
* top/bottom → ширина=sx, высота=sz
|
||||||
|
* UV-диапазон грани = (0,0)..(кол-во_studs_по_ширине, кол-во_по_высоте).
|
||||||
|
*/
|
||||||
|
function _studsCubeFaceUV(sx, sy, sz, density) {
|
||||||
|
const d = density && density > 0 ? density : 1;
|
||||||
|
const f = (STUD_UNIT * STUDS_GRID) / d;
|
||||||
|
const nx = Math.max(0.25, sx / f); // studs вдоль X
|
||||||
|
const ny = Math.max(0.25, sy / f); // studs вдоль Y
|
||||||
|
const nz = Math.max(0.25, sz / f); // studs вдоль Z
|
||||||
|
// Vector4(u0, v0, u1, v1)
|
||||||
|
return [
|
||||||
|
new Vector4(0, 0, nx, ny), // front (z-): X×Y
|
||||||
|
new Vector4(0, 0, nx, ny), // back (z+): X×Y
|
||||||
|
new Vector4(0, 0, nz, ny), // right (x+): Z×Y
|
||||||
|
new Vector4(0, 0, nz, ny), // left (x-): Z×Y
|
||||||
|
new Vector4(0, 0, nx, nz), // top (y+): X×Z
|
||||||
|
new Vector4(0, 0, nx, nz), // bottom (y-): X×Z
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
export class PrimitiveManager {
|
export class PrimitiveManager {
|
||||||
constructor(scene) {
|
constructor(scene) {
|
||||||
@ -111,6 +138,8 @@ export class PrimitiveManager {
|
|||||||
const isGlowingGd = isGdKind;
|
const isGlowingGd = isGdKind;
|
||||||
const isGdSpike = typeDef.kind === 'gd_spike';
|
const isGdSpike = typeDef.kind === 'gd_spike';
|
||||||
const material = opts.material ?? (isGlowingGd ? 'neon' : 'matte');
|
const material = opts.material ?? (isGlowingGd ? 'neon' : 'matte');
|
||||||
|
// studDensity — плотность кружков studs (1=стандарт, >1 мельче/чаще).
|
||||||
|
const studDensity = Number.isFinite(opts.studDensity) ? opts.studDensity : 1;
|
||||||
// canCollide: для всех GD-сущностей и шипов — false (игрок проходит сквозь, логика по дистанции)
|
// canCollide: для всех GD-сущностей и шипов — false (игрок проходит сквозь, логика по дистанции)
|
||||||
const canCollide = opts.canCollide !== false && !isGdKind && !isGdSpike;
|
const canCollide = opts.canCollide !== false && !isGdKind && !isGdSpike;
|
||||||
const visible = opts.visible !== false;
|
const visible = opts.visible !== false;
|
||||||
@ -128,7 +157,7 @@ export class PrimitiveManager {
|
|||||||
const rotationY = opts.rotationY ?? 0;
|
const rotationY = opts.rotationY ?? 0;
|
||||||
const rotationZ = opts.rotationZ ?? 0;
|
const rotationZ = opts.rotationZ ?? 0;
|
||||||
|
|
||||||
const mesh = this._createMeshForType(typeDef, id, sx, sy, sz);
|
const mesh = this._createMeshForType(typeDef, id, sx, sy, sz, material, studDensity);
|
||||||
mesh.position = new Vector3(x, y, z);
|
mesh.position = new Vector3(x, y, z);
|
||||||
mesh.rotation = new Vector3(rotationX, rotationY, rotationZ);
|
mesh.rotation = new Vector3(rotationX, rotationY, rotationZ);
|
||||||
mesh.isPickable = true;
|
mesh.isPickable = true;
|
||||||
@ -150,7 +179,7 @@ export class PrimitiveManager {
|
|||||||
id, mesh, type, x, y, z, sx, sy, sz,
|
id, mesh, type, x, y, z, sx, sy, sz,
|
||||||
rotationX, rotationY, rotationZ,
|
rotationX, rotationY, rotationZ,
|
||||||
color, material, canCollide, visible, anchored, mass,
|
color, material, canCollide, visible, anchored, mass,
|
||||||
textureAsset,
|
textureAsset, studDensity,
|
||||||
// locked — объект защищён от выделения/перемещения в редакторе
|
// locked — объект защищён от выделения/перемещения в редакторе
|
||||||
// (Фаза 5.11). На геймплей не влияет.
|
// (Фаза 5.11). На геймплей не влияет.
|
||||||
locked: opts.locked === true,
|
locked: opts.locked === true,
|
||||||
@ -158,7 +187,7 @@ export class PrimitiveManager {
|
|||||||
folderId: opts.folderId ?? null,
|
folderId: opts.folderId ?? null,
|
||||||
};
|
};
|
||||||
// Размеры для тайлинга studs-материала (читается в _applyMaterial).
|
// Размеры для тайлинга studs-материала (читается в _applyMaterial).
|
||||||
mesh._studsDims = { type, sx, sy, sz };
|
mesh._studsDims = { type, sx, sy, sz, density: studDensity };
|
||||||
this._applyMaterial(mesh, typeDef, color, material);
|
this._applyMaterial(mesh, typeDef, color, material);
|
||||||
this._applyVisible(mesh, visible, typeDef);
|
this._applyVisible(mesh, visible, typeDef);
|
||||||
// Пользовательская текстура — поверх базового материала.
|
// Пользовательская текстура — поверх базового материала.
|
||||||
@ -236,13 +265,17 @@ export class PrimitiveManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Создать базовый mesh нужной формы (без материала). */
|
/** Создать базовый mesh нужной формы (без материала). */
|
||||||
_createMeshForType(typeDef, id, sx, sy, sz) {
|
_createMeshForType(typeDef, id, sx, sy, sz, material, studDensity) {
|
||||||
const name = `prim_${typeDef.id}_${id}`;
|
const name = `prim_${typeDef.id}_${id}`;
|
||||||
switch (typeDef.id) {
|
switch (typeDef.id) {
|
||||||
case 'cube':
|
case 'cube':
|
||||||
case 'trigger':
|
case 'trigger': {
|
||||||
return MeshBuilder.CreateBox(name,
|
const boxOpts = { width: sx, height: sy, depth: sz };
|
||||||
{ width: sx, height: sy, depth: sz }, this.scene);
|
// studs — per-face UV, чтобы кружки были одного размера на всех
|
||||||
|
// гранях (не растягивались на длинной стороне).
|
||||||
|
if (material === 'studs') boxOpts.faceUV = _studsCubeFaceUV(sx, sy, sz, studDensity);
|
||||||
|
return MeshBuilder.CreateBox(name, boxOpts, this.scene);
|
||||||
|
}
|
||||||
case 'sphere':
|
case 'sphere':
|
||||||
return MeshBuilder.CreateSphere(name,
|
return MeshBuilder.CreateSphere(name,
|
||||||
{ diameterX: sx, diameterY: sy, diameterZ: sz, segments: 24 }, this.scene);
|
{ diameterX: sx, diameterY: sy, diameterZ: sz, segments: 24 }, this.scene);
|
||||||
@ -490,9 +523,16 @@ export class PrimitiveManager {
|
|||||||
const dt = tex.diffuse.clone();
|
const dt = tex.diffuse.clone();
|
||||||
const nt = tex.normal.clone();
|
const nt = tex.normal.clone();
|
||||||
const dims = mesh._studsDims || { type: 'cube', sx: 1, sy: 1, sz: 1 };
|
const dims = mesh._studsDims || { type: 'cube', sx: 1, sy: 1, sz: 1 };
|
||||||
const tile = _studsTiling(dims.type, dims.sx, dims.sy, dims.sz);
|
// Куб/триггер тайлятся через faceUV геометрии (uScale=1) — кружки
|
||||||
|
// одного размера на всех гранях. Остальные формы — через uScale.
|
||||||
|
if (dims.type === 'cube' || dims.type === 'trigger') {
|
||||||
|
dt.uScale = nt.uScale = 1;
|
||||||
|
dt.vScale = nt.vScale = 1;
|
||||||
|
} else {
|
||||||
|
const tile = _studsTiling(dims.type, dims.sx, dims.sy, dims.sz, dims.density);
|
||||||
dt.uScale = nt.uScale = tile.u;
|
dt.uScale = nt.uScale = tile.u;
|
||||||
dt.vScale = nt.vScale = tile.v;
|
dt.vScale = nt.vScale = tile.v;
|
||||||
|
}
|
||||||
mat.diffuseTexture = dt;
|
mat.diffuseTexture = dt;
|
||||||
mat.bumpTexture = nt;
|
mat.bumpTexture = nt;
|
||||||
const sc = Color3.FromHexString(color || '#cccccc');
|
const sc = Color3.FromHexString(color || '#cccccc');
|
||||||
@ -646,6 +686,12 @@ export class PrimitiveManager {
|
|||||||
if (patch.sx !== undefined) { data.sx = patch.sx; scaleChanged = true; }
|
if (patch.sx !== undefined) { data.sx = patch.sx; scaleChanged = true; }
|
||||||
if (patch.sy !== undefined) { data.sy = patch.sy; scaleChanged = true; }
|
if (patch.sy !== undefined) { data.sy = patch.sy; scaleChanged = true; }
|
||||||
if (patch.sz !== undefined) { data.sz = patch.sz; scaleChanged = true; }
|
if (patch.sz !== undefined) { data.sz = patch.sz; scaleChanged = true; }
|
||||||
|
// Плотность studs (мелкие/крупные кружки) — требует пересоздания меша
|
||||||
|
// (faceUV для куба зашит в геометрию).
|
||||||
|
if (patch.studDensity !== undefined) {
|
||||||
|
data.studDensity = Number.isFinite(patch.studDensity) ? patch.studDensity : 1;
|
||||||
|
scaleChanged = true;
|
||||||
|
}
|
||||||
if (scaleChanged) {
|
if (scaleChanged) {
|
||||||
// Поскольку MeshBuilder уже создал mesh с базовыми размерами,
|
// Поскольку MeshBuilder уже создал mesh с базовыми размерами,
|
||||||
// изменения через scaling кажутся правильными. Простой способ —
|
// изменения через scaling кажутся правильными. Простой способ —
|
||||||
@ -794,7 +840,7 @@ export class PrimitiveManager {
|
|||||||
const oldMat = oldMesh.material;
|
const oldMat = oldMesh.material;
|
||||||
|
|
||||||
const typeDef = getPrimitiveType(data.type);
|
const typeDef = getPrimitiveType(data.type);
|
||||||
const newMesh = this._createMeshForType(typeDef, data.id, data.sx, data.sy, data.sz);
|
const newMesh = this._createMeshForType(typeDef, data.id, data.sx, data.sy, data.sz, data.material, data.studDensity);
|
||||||
newMesh.position = oldPos;
|
newMesh.position = oldPos;
|
||||||
if (oldRot) newMesh.rotation = oldRot;
|
if (oldRot) newMesh.rotation = oldRot;
|
||||||
newMesh.material = oldMat;
|
newMesh.material = oldMat;
|
||||||
@ -807,10 +853,16 @@ export class PrimitiveManager {
|
|||||||
catch (e) { /* ignore */ }
|
catch (e) { /* ignore */ }
|
||||||
|
|
||||||
data.mesh = newMesh;
|
data.mesh = newMesh;
|
||||||
newMesh._studsDims = { type: data.type, sx: data.sx, sy: data.sy, sz: data.sz };
|
newMesh._studsDims = { type: data.type, sx: data.sx, sy: data.sy, sz: data.sz, density: data.studDensity };
|
||||||
// studs-материал: пересчитать тайлинг под новый размер меша.
|
// studs-материал: пересчитать тайлинг под новый размер меша.
|
||||||
|
// Куб уже пересоздан с новым faceUV (тайлинг в геометрии) — uScale=1.
|
||||||
|
// Для остальных форм пересчитываем uScale/vScale по размеру.
|
||||||
if (data.material === 'studs' && oldMat && oldMat.diffuseTexture) {
|
if (data.material === 'studs' && oldMat && oldMat.diffuseTexture) {
|
||||||
const tile = _studsTiling(data.type, data.sx, data.sy, data.sz);
|
if (data.type === 'cube' || data.type === 'trigger') {
|
||||||
|
oldMat.diffuseTexture.uScale = oldMat.diffuseTexture.vScale = 1;
|
||||||
|
if (oldMat.bumpTexture) oldMat.bumpTexture.uScale = oldMat.bumpTexture.vScale = 1;
|
||||||
|
} else {
|
||||||
|
const tile = _studsTiling(data.type, data.sx, data.sy, data.sz, data.studDensity);
|
||||||
oldMat.diffuseTexture.uScale = tile.u;
|
oldMat.diffuseTexture.uScale = tile.u;
|
||||||
oldMat.diffuseTexture.vScale = tile.v;
|
oldMat.diffuseTexture.vScale = tile.v;
|
||||||
if (oldMat.bumpTexture) {
|
if (oldMat.bumpTexture) {
|
||||||
@ -819,6 +871,7 @@ export class PrimitiveManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Удалить инстанс. */
|
/** Удалить инстанс. */
|
||||||
removeInstance(id) {
|
removeInstance(id) {
|
||||||
@ -870,6 +923,8 @@ export class PrimitiveManager {
|
|||||||
...(d.locked ? { locked: true } : {}),
|
...(d.locked ? { locked: true } : {}),
|
||||||
// id пользовательской текстуры (картинка из AssetManager).
|
// id пользовательской текстуры (картинка из AssetManager).
|
||||||
...(d.textureAsset ? { textureAsset: d.textureAsset } : {}),
|
...(d.textureAsset ? { textureAsset: d.textureAsset } : {}),
|
||||||
|
// Плотность studs (если не 1) — мелкие/крупные кружки.
|
||||||
|
...(d.studDensity && d.studDensity !== 1 ? { studDensity: d.studDensity } : {}),
|
||||||
// Параметры лампы (только для type='light', иначе undefined)
|
// Параметры лампы (только для type='light', иначе undefined)
|
||||||
...(d.light ? { brightness: d.brightness, range: d.range } : {}),
|
...(d.light ? { brightness: d.brightness, range: d.range } : {}),
|
||||||
// Параметр эмиттера (только для type='emitter')
|
// Параметр эмиттера (только для type='emitter')
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user