Alright, this tutorial will show how you can build ur own simple model editor(this is just a base) i will be including the source code below
I will be using JavaFX for this tutorial(i was actually gonna use OpenGL first but setting up picking, simple UI and a few other things would take longer and it would be less friendly for people not familiar with OpenGL) for the following reasons:
- I've used it for a while now(also built my model editor with it)
- UI
- To showcase triangle picking(JavaFX has it built in)
The editor shown in this tutorial will have the following features:
- RS Model Loading
- Model Rendering
- Simple orbit camera(can drag, zoom, move around the scene)
- 3D grid
- Color Painting
- Texture Painting
I am using Java 16 for this project
First of all create a project and include the JavaFX modules(controls, fxml):
Code:
id 'org.openjfx.javafxplugin' version '0.0.9'
javafx {
version = "17-ea+11" // feel free to use an earlier version
modules = [ 'javafx.controls', 'javafx.fxml']
}
Then create a new fxml file and add an AnchorPane(call it modelPane) this is where we will render our scene
Now it's time to setup the actual JavaFX Application, simply create a main class which extends the Application class and create the scene from the fxml file, it should look something like this:
Code:
import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import javafx.scene.Scene;
import javafx.stage.Stage;
public class Main extends Application {
@Override
public void start(Stage primaryStage) throws Exception {
FXMLLoader fxmlLoader = new FXMLLoader(getClass().getResource("/fxml/ui.fxml"));
Scene scene = new Scene(fxmlLoader.load());
scene.getStylesheets().add(getClass().getResource("/css/darktheme.css").toExternalForm());
primaryStage.setScene(scene);
primaryStage.setTitle("Simple Model Editor");
primaryStage.show();
}
}
Now lets create our main controller class(make sure you refer to it in the fxml file) It should look like this:
Code:
public class ModelController implements Initializable {
@FXML
private AnchorPane modelPane;
//initialize method
alright now you should be able to run the program and it should open an empty window
Now add the model class with necessary fields and the decoder, this part is self explanatory, you can simply copy it from ur client and remove unused parts
onto the 3d stuff now
so, we obviously need a camera and JavaFX has 2 built in camera implementations, Perspective camera and Parallel Camera(Orthographic) one of which is for perspective and the other for orthographic projection. Feel free to read more about it here: https://en.wikipedia.org/wiki/3D_projection
Lets setup a perspective camera with some controls now:
Code:
public class OrbitCamera {
private final SubScene subScene;
private final Group root3D;
private final double MAX_ZOOM = 300.0;
public OrbitCamera(SubScene subScene, Group root) {
this.subScene = subScene;
this.root3D = root;
init();
}
private void init() {
camera.setNearClip(0.1D);
camera.setFarClip(MAX_ZOOM * 1.15D);
camera.getTransforms().addAll(
yUpRotate,
cameraPosition,
cameraLookXRotate,
cameraLookZRotate
);
Group rotateGroup = new Group();
rotateGroup.getChildren().addAll(cameraXform);
cameraXform.ry.setAngle(180);
cameraXform.rx.setAngle(-18);
cameraXform.getChildren().add(cameraXform2);
cameraXform2.getChildren().add(cameraXform3);
cameraXform3.getChildren().add(camera);
cameraPosition.setZ(-cameraDistance);
root3D.getChildren().addAll(rotateGroup);
subScene.setCamera(camera);
subScene.setOnScroll(event -> {
double zoomFactor = 1.05;
double deltaY = event.getDeltaY();
if (deltaY < 0) {
zoomFactor = 2.0 - zoomFactor;
}
double z = cameraPosition.getZ() / zoomFactor;
z = Math.max(z, -MAX_ZOOM);
z = Math.min(z, 10.0);
cameraPosition.setZ(z);
});
subScene.setOnMousePressed(event -> {
if (!event.isAltDown()) {
mousePosX = event.getSceneX();
mousePosY = event.getSceneY();
mouseOldX = event.getSceneX();
mouseOldY = event.getSceneY();
}
});
subScene.setOnMouseDragged(event -> {
if (!event.isAltDown()) {
double modifier = 1.0;
double modifierFactor = 0.3;
if (event.isControlDown()) modifier = 0.1;
if (event.isSecondaryButtonDown()) modifier = 0.035;
mouseOldX = mousePosX;
mouseOldY = mousePosY;
mousePosX = event.getSceneX();
mousePosY = event.getSceneY();
mouseDeltaX = mousePosX - mouseOldX;
mouseDeltaY = mousePosY - mouseOldY;
double flip = -1.0;
if (event.isSecondaryButtonDown()) {
double newX = cameraXform2.t.getX() + flip * mouseDeltaX * modifierFactor * modifier * 2.0;
double newY = cameraXform2.t.getY() + 1.0 * -mouseDeltaY * modifierFactor * modifier * 2.0;
cameraXform2.t.setX(newX);
cameraXform2.t.setY(newY);
} else if (event.isPrimaryButtonDown()) {
double yAngle = cameraXform.ry.getAngle() - 1.0 * -mouseDeltaX * modifierFactor * modifier * 2.0;
double xAngle = cameraXform.rx.getAngle() + flip * mouseDeltaY * modifierFactor * modifier * 2.0;
cameraXform.ry.setAngle(yAngle);
cameraXform.rx.setAngle(xAngle);
}
}
});
}
private final Camera camera = new PerspectiveCamera(true);
private final Rotate cameraXRotate = new Rotate(-20.0, 0.0, 0.0, 0.0, Rotate.X_AXIS);
private final Rotate cameraYRotate = new Rotate(-20.0, 0.0, 0.0, 0.0, Rotate.Y_AXIS);
private final Rotate cameraLookXRotate = new Rotate(0.0, 0.0, 0.0, 0.0, Rotate.X_AXIS);
private final Rotate cameraLookZRotate = new Rotate(0.0, 0.0, 0.0, 0.0, Rotate.Z_AXIS);
private final Translate cameraPosition = new Translate(0.0, 0.0, 0.0);
private Xform cameraXform = new Xform();
private Xform cameraXform2 = new Xform();
private Xform cameraXform3 = new Xform();
private double cameraDistance = 25.0;
private double mousePosX = 0;
private double mousePosY = 0;
private double mouseOldX = 0;
private double mouseOldY = 0;
private double mouseDeltaX = 0;
private double mouseDeltaY = 0;
private final Rotate yUpRotate = new Rotate(0.0, 0.0, 0.0, 0.0, Rotate.X_AXIS);
}
Im not going to explain how all of the camera math works on this thread, but if ur curious feel free to ask me and i can explain it
Now lets create a class that will represent our mesh, lets call it RSMeshGroup (I'll explain why it's called that way in a moment)
Alright so to represent a model made of triangles JavaFX has a TriangleMesh class built in, which has the following properties:
- Points(Vertices)
- Normals
- Texture coordinates
- Faces(indices)
- Smoothing Groups
For this tutorial we'll only care about the points, faces and texture coordinates
the RSMeshGroup class should have the following properties:
the model itself, a list of MeshView's and the model scale, the model should be initialized inside the constructor
now lets actually build the mesh, or actually meshes because we are going to build a mesh per triangle, now you might be wondering why in the world, isn't that super inefficient? Indeed it is but it's not always possible to build it as 1 mesh either, lets see why
So a runescape model can either have a solid color or a texture as a material for each triangle, but triangle meshes in JavaFX can only have 1 material per mesh, so you might think "dang i guess i really have to build a mesh per triangle" fortunately not, for a mesh with only solid colors we could build a color atlas (which would be an image) and then we would use the proper UV's(texture coordinates) to give each face(triangle) the correct color.
For a mesh with both solid colors and textures we can also optimize it by grouping the textures, for example: if we had a mesh with 200 triangles where 150 triangles have solid colors, 20 triangles texture1, 30 triangles texture2 we could build it as only 3 meshes instead of 200
For this tutorial i will be implementing it the simple & inefficient way as i also want to showcase triangle picking(for color, texture painting) and it's just much simpler that way, however if you plan to make your own editor definitely implement what i explained above(especially when ur going to add animation support and other features where the mesh constantly changes)
Code:
public void buildMeshes() {
for (int face = 0; face < model.triangleCount; face++) {
TriangleMesh mesh = new TriangleMesh();
int faceA = model.faceIndicesA[face];
int faceB = model.faceIndicesB[face];
int faceC = model.faceIndicesC[face];
Vector3i v1 = new Vector3i(model.verticesXCoordinate[faceA], model.verticesYCoordinate[faceA], model.verticesZCoordinate[faceA]);
Vector3i v2 = new Vector3i(model.verticesXCoordinate[faceB], model.verticesYCoordinate[faceB], model.verticesZCoordinate[faceB]);
Vector3i v3 = new Vector3i(model.verticesXCoordinate[faceC], model.verticesYCoordinate[faceC], model.verticesZCoordinate[faceC]);
mesh.getPoints()
.addAll(v1.x(), v1.y(), v1.z(), v2.x(), v2.y(), v2.z(), v3.x(), v3.y(), v3.z());
mesh.getFaces().addAll(
0, 0, 1, 1, 2, 2
);
mesh.getTexCoords().addAll(0f, 0f, 1f, 0f, 0f, 1f);
MeshView view = new MeshView(mesh);
view.getTransforms().add(new Scale(MODEL_SCALE, MODEL_SCALE, MODEL_SCALE));
view.setMaterial(new PhongMaterial(ColorUtils.rs2HSLToColor(model.triangleColors[face], model.faceAlpha == null ? 0 : model.faceAlpha[face])));
meshes.add(view);
}
}
Now lets go back to our controller class and create the model:
Code:
byte[] modelData = Files.readAllBytes(Path.of("./9638.dat")); // fire cape
Model model = Model.decode(modelData);
and finally we can build our 3D scene
Code:
private void initScene(Model model) {
meshGroup = new RSMeshGroup(model);
meshGroup.buildMeshes(); // build the triangle meshes
scene = buildScene();
SubScene subScene = create3DScene();
scene.getChildren().add(new AmbientLight(Color.WHITE));
modelPane.getChildren().addAll(subScene);
}
private Group buildScene() {
Group group = new Group();
group.getChildren().addAll(meshGroup.getMeshes()); // add our built meshes to the group
return group;
}
private SubScene create3DScene() {
SubScene scene3D = new SubScene(scene, modelPane.getPrefWidth(), modelPane.getPrefHeight(), true, SceneAntialiasing.BALANCED);
scene3D.setFill(Color.rgb(30, 30, 30));
new OrbitCamera(scene3D, scene);
return scene3d;
}
Running this should render the model and the camera controls should work aswell, lets also add a 3D grid
before we build the sub scene:
Code:
Group grid = new Grid3D().create(48f, 1.25f);
scene.getChildren().add(grid);
building the grid relies on a lot of other classes which i won't include in this thread(will be included in the source code)
Result:
Looks good but we haven't added support for textured triangles yet, fortunately this is easy to do.
For the sake of this tutorial lets just load the fire cape texture
Code:
private final Image texture = new Image("file:40.png");
Now lets compute the UV coordinates from our texture coordinates in RS format(a 3D triangle)
Code:
public void computeUVCoordinates() {
if (texturedFaces == 0) {
return;
}
textureUCoordinates = new float[triangleCount][];
textureVCoordinates = new float[triangleCount][];
for (int i = 0; i < triangleCount; i++) {
int coordinate = triangleInfo == null ? -1 : triangleInfo[i] >> 2;
int textureIdx;
if (triangleInfo == null || triangleInfo[i] < 2) {
textureIdx = -1;
} else {
textureIdx = triangleColors[i] & 0xFFFF;
}
if (textureIdx != -1) {
float[] u = new float[3];
float[] v = new float[3];
if (coordinate == -1) {
u[0] = 0.0F;
v[0] = 1.0F;
u[1] = 1.0F;
v[1] = 1.0F;
u[2] = 0.0F;
v[2] = 0.0F;
} else {
coordinate &= 0xFF;
int faceA = faceIndicesA[i];
int faceB = faceIndicesB[i];
int faceC = faceIndicesC[i];
Point3D a = new Point3D(verticesXCoordinate[faceA], verticesYCoordinate[faceA], verticesZCoordinate[faceA]);
Point3D b = new Point3D(verticesXCoordinate[faceB], verticesYCoordinate[faceB], verticesZCoordinate[faceB]);
Point3D c = new Point3D(verticesXCoordinate[faceC], verticesYCoordinate[faceC], verticesZCoordinate[faceC]);
Point3D p = new Point3D(verticesXCoordinate[textureVertexA[coordinate]], verticesYCoordinate[textureVertexA[coordinate]], verticesZCoordinate[textureVertexA[coordinate]]);
Point3D m = new Point3D(verticesXCoordinate[textureVertexB[coordinate]], verticesYCoordinate[textureVertexB[coordinate]], verticesZCoordinate[textureVertexB[coordinate]]);
Point3D n = new Point3D(verticesXCoordinate[textureVertexC[coordinate]], verticesYCoordinate[textureVertexC[coordinate]], verticesZCoordinate[textureVertexC[coordinate]]);
Point3D pM = m.subtract(p);
Point3D pN = n.subtract(p);
Point3D pA = a.subtract(p);
Point3D pB = b.subtract(p);
Point3D pC = c.subtract(p);
Point3D pMxPn = pM.crossProduct(pN);
Point3D uCoordinate = pN.crossProduct(pMxPn);
double mU = 1.0F / uCoordinate.dotProduct(pM);
double uA = uCoordinate.dotProduct(pA) * mU;
double uB = uCoordinate.dotProduct(pB) * mU;
double uC = uCoordinate.dotProduct(pC) * mU;
Point3D vCoordinate = pM.crossProduct(pMxPn);
double mV = 1.0 / vCoordinate.dotProduct(pN);
double vA = vCoordinate.dotProduct(pA) * mV;
double vB = vCoordinate.dotProduct(pB) * mV;
double vC = vCoordinate.dotProduct(pC) * mV;
u[0] = (float) uA;
u[1] = (float) uB;
u[2] = (float) uC;
v[0] = (float) vA;
v[1] = (float) vB;
v[2] = (float) vC;
}
this.textureUCoordinates[i] = u;
this.textureVCoordinates[i] = v;
}
}
}
If you care about the derivation: https://nothings.org/gamedev/ray_plane.html
Lets also call the computeUVCoordinates() method at the beginning of buildMeshes()
Then for each triangle we check if the triangle is textured or not:
Code:
boolean textured = model.triangleInfo[face] >= 2;
and based on that we set the texture coordinates, material:
Code:
if (textured) {
mesh.getTexCoords()
.addAll(model.textureUCoordinates[face][0], model.textureVCoordinates[face][0]);
mesh.getTexCoords()
.addAll(model.textureUCoordinates[face][1], model.textureVCoordinates[face][1]);
mesh.getTexCoords()
.addAll(model.textureUCoordinates[face][2], model.textureVCoordinates[face][2]);
} else {
mesh.getTexCoords().addAll(0f, 0f, 1f, 0f, 0f, 1f);
}
if (textured) {
PhongMaterial mat = new PhongMaterial();
mat.setDiffuseMap(texture);
view.setMaterial(mat);
} else {
view.setMaterial(new PhongMaterial(ColorUtils.rs2HSLToColor(model.triangleColors[face], model.faceAlpha == null ? 0 : model.faceAlpha[face])));
}
Now if we run the program again, we should see this:
Alright cool we got our model to render properly, lets add support for simple editing now, ill add the following:
- Color painting
- Texture painting
Also perhaps a wireframe option?
Lets add a color picker and 2 check boxes (for texture painting and wireframe mode)
Code:
@FXML
private ColorPicker colorPicker;
@FXML
private CheckBox paintTexture, wireframe;
now lets define a SharedProperties class and add the 3 properties there
Code:
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.scene.paint.Color;
import lombok.Getter;
@Getter
public final class SharedProperties {
private final ObjectProperty<Color> selectedColor = new SimpleObjectProperty<>(Color.RED);
private final BooleanProperty paintTexture = new SimpleBooleanProperty();
private final BooleanProperty wireframe = new SimpleBooleanProperty();
private static SharedProperties instance;
public static SharedProperties getInstance() {
if (instance == null) {
instance = new SharedProperties();
}
return instance;
}
private SharedProperties() {
}
}
Now lets bind them
Code:
private void initBindings() {
sharedProperties.getSelectedColor().bind(colorPicker.valueProperty());
sharedProperties.getPaintTexture().bind(paintTexture.selectedProperty());
sharedProperties.getWireframe().bind(wireframe.selectedProperty());
}
call this before u init the scene
Now back to our buildMeshes() method, lets bind the draw mode
Code:
view.drawModeProperty().bind(
Bindings.when(
sharedProperties.getWireframe())
.then(DrawMode.LINE)
.otherwise(DrawMode.FILL)
);
and we should have the wireframe mode working:
Alright, now for the color, texture painting lets setup 2 listeners:
Code:
private void initListeners(MeshView view) {
view.setOnMouseClicked(event -> {
paint(view);
});
view.setOnMouseEntered(event -> {
if (event.isAltDown()) {
paint(view);
}
});
}
and our paint method:
Code:
private void paint(MeshView view) {
boolean paintTexture = sharedProperties.getPaintTexture().get();
if (paintTexture) {
PhongMaterial mat = new PhongMaterial();
mat.setDiffuseMap(texture);
view.setMaterial(mat);
} else {
Color selectedColor = sharedProperties.getSelectedColor().get();
view.setMaterial(new PhongMaterial(selectedColor));
}
}
of course this doesn't support picking & setting ur own texture coordinates but that is very easy to add and i'll leave that up to you
Result:
As you can see building ur own simple editor that renders the model and lets u paint colors and textures is not difficult at all
oh yeah i posted this under the RS2 Client section cause i had no clue which other section would fit better
Edit: There is another way to render the mesh even more efficiently (1 mesh for the whole model and it doesn't matter how many colors or textures there are which p much means 1 render call) simply build a texture from the mesh colors, textures. If you have meshes with out of bounds UV coordinates (models that 'abuse' texture wrapping usually use < 0, > 1 UVs) then you need to build a 3x3 square of the texture and use the center one relatively
for storing the colors you can just build width colors per row
Source code: https://github.com/Suicolen/Simple-JFX-Model-Editor