为 Compose MultiPlatform 添加 C/C++ 支持(2):在 jvm 平台使用 jni 实现桌面端与 C/C++ 互操作

news2025/3/9 22:39:32

前言

在上篇文章中我们已经介绍了实现 Compose MultiPlatform 对 C/C++ 互操作的基本思路。

并且先介绍了在 kotlin native 平台使用 cinterop 实现与 C/C++ 的互操作。

今天这篇文章将补充在 jvm 平台使用 jni。

在 Compose MultiPlatform 中,使用 jvm 平台的是 Android 端和 Desktop 端,而安卓端可以直接使用安卓官方的 NDK 实现交叉编译,但是 Desktop 不仅不支持交叉编译,甚至连使用 Gradle 自动编译都没有。

所以本文重点主要在于实现 Desktop 的 jni 编译以及调用编译出来的二进制库。

Android 使用 jni

在介绍 Desktop 使用 jni 之前,我们先回顾一下在 Android 中使用 jni,并复用 Android 端的 C++ 代码给 Desktop 使用。

感谢谷歌的工作,在安卓中使用 jni 非常简单,我们只需要在 Android Studio 随便打开一个已有的项目,然后依次选择菜单 File - New - New Module - Android Native Library,保持默认参数,点击 Finish 即可完成创建安卓端的 jni 模块。

这里我们以 jetBrains 的官方 Compose MultiPlatform 模板 项目作为示例:

1.jpg

创建完成后需要注意,Android studio 会自动修改项目 settings.gradle.kts 在其中添加一个插件 org.jetbrains.kotlin.android ,这会导致编译错误 java.lang.IllegalArgumentException: Cannot provide multiple default versions for the same plugin.,所以需要我们删掉新添加的这个插件:

2.jpg

然后在 shared 模块中的 build.gradle.kts 文件的 Android 依赖部分引入 nativelib 模块:

kotlin {

	// ……

    sourceSets {

    	// ……

        val androidMain by getting {
            dependencies {
                // ……

                api(project(":nativelib"))
            }
        }

        // ……

    }
}

接着,需要注意 nativelib 模块的两个文件 native.cppNativeLib.kt

3.jpg

我们看一下 nativelib 模块中的 nativelib.cpp 文件的默认内容:

#include <jni.h>
#include <string>

extern "C" JNIEXPORT jstring JNICALL
Java_com_equationl_nativelib_NativeLib_stringFromJNI(
        JNIEnv* env,
        jobject /* this */) {
    std::string hello = "C++";
    return env->NewStringUTF(hello.c_str());
}

代码很简单,就是返回一个字符串 “Hello from C++”,我们改成返回 “C++”。

这里需要注意这个函数的名称: Java_com_equationl_nativelib_NativeLib_stringFromJNI

开头的 “Java” 是固定字符,后面的 “com_equationl_nativelib_NativeLib” 表示从 java 调用时的类的包名+类名,最后的 “stringFromJNI” 才是这个函数的名称。

通过 jni 从 java(kt)中调用这个函数时必须确保其包名和类名与其一致才能成功调用。

然后查看 NativeLib.kt 文件:

class NativeLib {
    external fun stringFromJNI(): String

    companion object {
        init {
            System.loadLibrary("nativelib")
        }
    }
}

其中 external fun stringFromJNI(): String 表示需要调用的 c++ 函数名。

System.loadLibrary("nativelib") 表示加载 C++ 编译生成的二进制库,这里我们无需关心具体的编译过程和编译产物,只需要直接加载 nativelib 即可,剩下的工作 NDK 已经替我们完成了。

最后,我们来调用一下这个 C++ 函数。

不过在此之前先简单介绍一下我们用作示例的这个 Compose MultiPlatform 的内容,它的 UI 就是一个按钮,按钮默认显示 “Hello, World!”,当点击按钮后会通过一个 expect 函数获取当前平台的名称然后显示到按钮上:

@OptIn(ExperimentalResourceApi::class)
@Composable
fun App() {
    MaterialTheme {
        var greetingText by remember { mutableStateOf("Hello, World!") }
        var showImage by remember { mutableStateOf(false) }
        Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
            Button(onClick = {
                greetingText = "Hello, ${getPlatformName()}"
                showImage = !showImage
            }) {
                Text(greetingText)
            }
            AnimatedVisibility(showImage) {
                Image(
                    painterResource("compose-multiplatform.xml"),
                    contentDescription = "Compose Multiplatform icon"
                )
            }
        }
    }
}

expect fun getPlatformName(): String

所以接下来我们修改安卓平台的 getPlatformName 函数的 actual 实现,由:

actual fun getPlatformName(): String = "Android"

修改为:

actual fun getPlatformName(): String = NativeLib().stringFromJNI()

这样,它获取的名称就是来自 C++ 代码的 “C++” 了。

运行代码,可以看到完美符合预期:

4.gif

Desktop 使用 jni

上一节我们已经完成了在 Android 中使用 jni,本节我们将在 Desktop 中也实现使用 jni,并且复用上节中的 nativelib.cpp 文件。

因为直接使用 Gradle 编译 C++ 代码不是很方便,而且还不支持交叉编译,所以这里我们首先手动编译,验证可行后再自己编写 gradle 脚本实现自动编译。

有关编写 gradle 脚本的基础知识可以阅读我之前的文章 Compose Desktop 使用中的几个问题(分平台加载资源、编写Gradle 任务下载平台资源、桌面特有组件、鼠标&键盘事件) 了解。

首先,我们可以使用命令 g++ nativelib.cpp -o nativelib.bin -shared -fPIC -I C:\Users\equationl\.jdks\corretto-19.0.2\include -I C:\Users\equationl\.jdks\corretto-19.0.2\include\win32 编译我们的 C++ 文件为当前平台可用的二进制文件。

上述命令中 nativelib.cpp 即需要编译的文件,nativelib.bin 为输出的二进制文件,C:\Users\equationl\.jdks\corretto-19.0.2\ 为你电脑上安装的任意的 jdk 目录。

输入 “ j d k P a t h / i n c l u d e " 和 " jdkPath/include" 和 " jdkPath/include""jdkPath/include/win32” 是因为这两个目录下有我们的 C++ 文件导入所需的头文件,如 “jni.h” 。

切换到我们的 C++ 文件所在目录后执行上述命令编译:

5.jpg

此时我们可以看到在 “./nativelib/src/main/cpp” 目录下已经生成了 nativelib.bin 文件。

注意:在 macOS 上系统自带了 g++ 命令,但是一般来说 Windows 系统没有自带 g++ 命令,所以需要先自己安装 g++

然后,我们在 sahred 模块下的 desktopMain 包中新建一个文件 NativeLib.kt ,注意该文件的包名需要和 C++ 定义的一致:

6.jpg

然后编写该文件内容为:

package com.equationl.nativelib

class NativeLib {
    external fun stringFromJNI(): String

    companion object {
        init {
            System.load("D:\\project\\ideaProject\\compose-multiplatform-c-test\\nativelib\\src\\main\\cpp\\nativelib.bin")
        }
    }
}

可以看到在 Desktop 中加载二进制库和 Android 中略有不同,它使用的是 System.load() 而不是 System.loadLibrary() ,并且加载二进制文件时使用的是绝对路径。

这是因为我们无法在 Desktop 中像 Android 一样直接把二进制文件打包到指定的路径下并且直接使用库名通过 System.loadLibrary() 加载,所以只能使用绝对路径加载外部二进制文件。

这里我们把加载的文件路径写为了先前生成的 nativelib.bin 的路径。

接着,依旧是修改 dektop 的 getPlatformName 函数的实现为:

actual fun getPlatformName(): String = NativeLib().stringFromJNI()

然后运行 Desktop 程序:

7.gif

运行结果完美符合预期。

为 Desktop 实现自动编译 C++

在上一节中我们已经实现了 Desktop 使用 jni 并验证了可行性,但是目前还是手动编译代码,这显然是不现实的,所以我们本节将讲解如何自己编写脚本实现自动编译。

另外,上一节中我们说过, Dektop 加载二进制文件使用的是绝对路径,所以我们需要将编译生成的二进制文件放到指定位置并打包进 Desktop 程序安装包中,Desktop 在安装时会自动将这个文件解压到指定路径,关于这个的基础知识还是可以看我的文章 Compose Desktop 使用中的几个问题(分平台加载资源、编写Gradle 任务下载平台资源、桌面特有组件、鼠标&键盘事件) 了解。

首先,需要指定一下资源文件目录,在 desktopApp 模块的 buiuld.gradle.kts 文件中添加以下内容:

compose.desktop {
    application {

    	// ……

        nativeDistributions {

        	// ……

            appResourcesRootDir.set(project.layout.projectDirectory.dir("resources"))
        }
    }
}

指定资源目录为 resources

然后依旧是在这个文件中,添加一个函数 runCommand,用于执行 shell 命令:

fun runCommand(command: String, timeout: Long = 120): Pair<Boolean, String> {
    val process = ProcessBuilder()
        .command(command.split(" "))
        .directory(rootProject.projectDir)
        .redirectOutput(ProcessBuilder.Redirect.INHERIT)
        .redirectError(ProcessBuilder.Redirect.INHERIT)
        .start()
    process.waitFor(timeout, TimeUnit.SECONDS)
    val result = process.inputStream.bufferedReader().readText()
    val error = process.errorStream.bufferedReader().readText()
    return if (error.isBlank()) {
        Pair(true, result)
    }
    else {
        Pair(false, error)
    }
}

代码很简单,接收一个字符串表示的 shell 命令,返回一个 Pair ,第一个 booean 数据表示是否执行成功;第二个 String 是输出内容。

接着注册一个 task:

tasks.register("compileJni") { }

修改原有的 prepareAppResources task,添加上我们刚注册的 compileJni 为它的依赖:

gradle.projectsEvaluated {
    tasks.named("prepareAppResources") {
        dependsOn("compileJni")
    }
}

这里的修改依赖需要加在 gradle.projectsEvaluated 语句中,因为 prepareAppResources 这个 task 推迟了注册,如果不在项目配置完成后再修改依赖的话会报 prepareAppResources 不存在。

注:这里的 prepareAppResources 是 task 模块中用于执行复制和打包资源文件的 task,所以我们把自定义的 compileJni 添加成它的依赖,以保证在它之前执行。

另外,这里必须明确保证 compileJniprepareAppResources 之前执行,否则由于我们的 compileJni 任务的输出路径和 prepareAppResources 任务的输出路径冲突,会导致编译失败,具体后面详细解释。

接着,在 compileJni task 中编写我们的编译逻辑,我们先看一下完整的代码,然后再逐一解释:

tasks.register("compileJni") {
    description = "compile jni binary file for desktop"

    val resourcePath = File(rootProject.projectDir, "desktopApp/resources/common/lib/")
    val binFilePath = File(resourcePath, "nativelib.bin")
    val cppFileDirectory = File(rootProject.projectDir, "nativelib/src/main/cpp")
    val cppFilePath = File(cppFileDirectory, "nativelib.cpp")

    // 指定输入、输出文件,用于增量编译
    inputs.dir(cppFileDirectory)
    outputs.file(binFilePath)

    doLast {
        project.logger.info("compile jni for desktop running……")

        val jdkFile = org.gradle.internal.jvm.Jvm.current().javaHome
        val systemPrefix: String

        val os: OperatingSystem = DefaultNativePlatform.getCurrentOperatingSystem()

        if (os.isWindows) {
            systemPrefix = "win32"
        }
        else if (os.isMacOsX) {
            systemPrefix = "darwin"
        }
        else if (os.isLinux) {
            systemPrefix = "linux"
        }
        else {
            project.logger.error("UnSupport System for compiler cpp, please compiler manual")
            return@doLast
        }

        val includePath1 = jdkFile.resolve("include")
        val includePath2 = includePath1.resolve(systemPrefix)

        if (!includePath1.exists() || !includePath2.exists()) {
            val msg = "ERROR: $includePath2 not found!\nMaybe it's because you are using JetBrain Runtime (Jbr)\nTry change Gradle JDK to another jdk which provide jni support"
            throw GradleException(msg)
        }

        project.logger.info("Check Desktop Resources Path……")

        if (!resourcePath.exists()) {
            project.logger.info("${resourcePath.absolutePath} not exists, create……")
            mkdir(resourcePath)
        }

        val runTestResult = runCommand("g++ --version")
        if (!runTestResult.first) {
            throw GradleException("Error: Not find command g++, Please install it and add to your system environment path\n${runTestResult.second}")
        }

        val command = "g++ ${cppFilePath.absolutePath} -o ${binFilePath.absolutePath} -shared -fPIC -I ${includePath1.absolutePath} -I ${includePath2.absolutePath}"

        project.logger.info("running command $command……")

        val compilerResult = runCommand(command)

        if (!compilerResult.first) {
            throw GradleException("Command run fail: ${compilerResult.second}")
        }

        project.logger.info(compilerResult.second)

        project.logger.lifecycle("compile jni for desktop all done")
    }
}

首先,在 task 顶级定义了四个路径: resourcePathbinFilePathcppFileDirectorycppFilePath,分别表示需要存放二进制文件的资源目录、二进制文件输出路径、C++文件存放目录和需要编译的具体 C++ 文件路径。

rootProject.projectDir 返回的是当前项目的根目录。

接着,我们通过 inputs.dir() 方法添加了该 task 的输入路径。

outputs.file 方法添加了该 task 的输出文件。

定义输入路径和输出文件与我们这里需要执行的编译没有直接关联,这里定义这个两个路径是为了让 Gradle 实现增量编译,即只有在上次编译完成后输入路径的中的文件内容发生了变化或输出文件发生了变化才会继续执行这个 task,否则会认为这个 task 没有变化,不会执行,表现在编译输出日志则为:

> Task :desktopApp:compileJni UP-TO-DATE

接下来,我们的代码写在了 doLast { } 语句中,则表示里面的代码只有在编译阶段才会执行,在配置阶段不会执行。

在其中的 org.gradle.internal.jvm.Jvm.current().javaHome 返回的是当前项目 Gradle 使用的 jdk 根目录。

然后,我们需要拼接出编译时需要导入的两个 jdk 路径 includePath1includePath2 ,其中的 includePath2 不同的系统名称不一样,所以需要判断一下当前编译使用的系统并更改该值。 可以通过 DefaultNativePlatform.getCurrentOperatingSystem().isXXX 判断当前是否是某个系统。

接着,检查存放二进制文件的目录是否存在,不存在则创建。

下一步是使用 g++ --version 测试是否安装了 g++ 。

最后,拼接出编译命令后执行编译:

g++ ${cppFilePath.absolutePath} -o ${binFilePath.absolutePath} -shared -fPIC -I ${includePath1.absolutePath} -I ${includePath2.absolutePath}

此时如果编译成功,那么二进制文件会输出到我们指定的 dektop 资源目录下。

我们现在只需要修改 dektop 加载二进制文件的代码为:

val libFile = File(System.getProperty("compose.application.resources.dir")).resolve("lib").resolve("nativelib.bin")
System.load(libFile.absolutePath)

上述代码中 System.getProperty("compose.application.resources.dir") 返回的是我们最开始在 Gradle 中定义的资源打包安装解压后在系统上的绝对路径。

至此,我们的自动编译已经完成!

最后来说一下我们前面提到的为什么我们的 compileJni task 必须在 prepareAppResources 之前执行,我们现在直接把原本的修改 prepareAppResources 依赖于 compileJni 改成 Desktop 模块执行的第一个 task compileKotlinJvm 依赖 compileJni

tasks.named("compileKotlinJvm") {
    dependsOn("compileJni")
}

运行后会看到报错:

A problem was found with the configuration of task ':desktopApp:prepareAppResources' (type 'Sync').
  - Gradle detected a problem with the following location: '/Users/equationl/AndroidStudioProjects/life-game-compose/desktopApp/resources/common'.
    
    Reason: Task ':desktopApp:prepareAppResources' uses this output of task ':desktopApp:compileJni' without declaring an explicit or implicit dependency. This can lead to incorrect results being produced, depending on what order the tasks are executed.
    
    Possible solutions:
      1. Declare task ':desktopApp:compileJni' as an input of ':desktopApp:prepareAppResources'.
      2. Declare an explicit dependency on ':desktopApp:compileJni' from ':desktopApp:prepareAppResources' using Task#dependsOn.
      3. Declare an explicit dependency on ':desktopApp:compileJni' from ':desktopApp:prepareAppResources' using Task#mustRunAfter.

简单说就是 prepareAppResourcescompileJni 都声明了同一个输出路径,除非明确指定它们两个之间的依赖关系,否则编译会出现问题。

其实也很好理解,他们的输出路径都是一个,如果不明确依赖关系的话增量编译就永远不会触发了,永远都将是全量编译。

而在这里我们的需求是首先使用 compileJni 生成二进制文件后,由 prepareAppResources 将其打包,所以自然应该是写成 prepareAppResources 依赖于 compileJni

最后,还是需要强调一点,Desktop 编译 C++ 是不支持交叉编译的,也就是说在 Windows 只能编译 Windows 的程序,在 macOS 只能 编译 macOS 的程序。

其实即使 C++ 可以交叉编译也没用,因为 Compose Desktop 并不支持交叉编译,哈哈哈。

参考资料

  1. Native dependency in Kotlin/Multiplatform — part 2: JNI for JVM & Android
  2. Kotlin JNI for Native Code

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1301074.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

React antd如何实现<Upload>组件上传附件再次上传已清除附件缓存问题

最近遇到一个React上传组件的问题&#xff0c;即上传附件成功后&#xff0c;文件展示处仍然还有之前上传附件的缓存信息&#xff0c;需要解决的问题是&#xff0c;要把上一次上传的附件缓存在上传成功或者取消后&#xff0c;可以进行清除 经过一顿试错&#xff0c;终于解决了这…

模块一——双指针:611.有效三角形的个数

文章目录 题目描述算法原理解法一&#xff1a;暴力求解(超时&#xff09;解法二&#xff1a;排序&#xff0b;双指针 代码实现 题目描述 题目链接&#xff1a;611.有效三角形的个数 算法原理 解法一&#xff1a;暴力求解(超时&#xff09; 三层for循环枚举出所有的三元组&…

Linux常见压缩指令小结

为什么需要压缩技术 我们都知道文件是以byte作为单位的&#xff0c;如果我们的文件仅仅在低位占一个1 0000 0001这种情况我们完全可以压缩一下&#xff0c;将高位的0全部抹掉即可。 如上所说是一种压缩技术&#xff0c;还有一种就是将1111(此处省略96个)一共100个1&#xff0…

键盘打字盲打练习系列之成为大师——5

一.欢迎来到我的酒馆 盲打&#xff0c;成为大师&#xff01; 目录 一.欢迎来到我的酒馆二.关于盲打你需要知道三.值得收藏的练习打字网站 二.关于盲打你需要知道 盲打系列教程&#xff0c;终于写到终章了。。。一开始在看网上视频&#xff0c;看到up主熟练的打字技巧&#xff…

mapstruct个人学习记录

mapstruct核心技术学习 简介入门案例maven依赖 IDEA插件单一对象转换测试结果 mapping属性Spring注入的方式测试 集合的映射set类型的映射测试map类型的映射测试 MapMappingkeyDateFormatvalueDateFormat 枚举映射基础入门 简介 在工作中&#xff0c;我们经常要进行各种对象之…

综述 2022-Genome Biology:“AI+癌症multi-omics”融合方法benchmark

Leng, Dongjin, et al. "A benchmark study of deep learning-based multi-omics data fusion methods for cancer." Genome biology 23.1 (2022): 1-32. 被引次数&#xff1a;34作者单位 红色高亮表示写论文中可以借鉴的地方 一、方法和数据集 1. 3个数据集&…

【数学建模】《实战数学建模:例题与讲解》第八讲-回归分析(含Matlab代码)

【数学建模】《实战数学建模&#xff1a;例题与讲解》第八讲-回归分析&#xff08;含Matlab代码&#xff09; 回归分析基本概念经典多元线性回归&#xff08;MLR&#xff09;主成分回归&#xff08;PCR&#xff09;偏最小二乘回归&#xff08;PLS&#xff09;建模过程应用和优势…

Vue3中的defineModel

目录 一、vue3的defineModel介绍 二、defineModel使用 &#xff08;1&#xff09;在vite.config.js中开启 &#xff08;2&#xff09;子组件 &#xff08;3&#xff09;父组件 一、vue3的defineModel介绍 为什么要使用到defineModel呢&#xff1f;这里有这样一种场景&…

面向对象类的设计和实现

实验目标 本实验任务是实现 Java 类的设计和实现&#xff0c;实验任务是根据每年新生的报到流程&#xff0c; 设计一 个学生管理系统&#xff0c;实现学生的注册和报到功能。设置类的基本属性&#xff0c;实现 getter 和 setter 方 法&#xff0c;通过 set 方法设置…

【第三届】:“玄铁杯”RISC-V应用创新大赛(基于yolov5和OpenCv算法 — 智能警戒哨兵)

文章目录 前言 一、智能警戒哨兵是什么&#xff1f; 二、方案流程图 三、硬件方案 四、软件方案 五、演示视频链接 总结 前言 最近参加了第三届“玄铁杯”RISC-V应用创新大赛&#xff0c;我的创意题目是基于 yolov5和OpenCv算法 — 智能警戒哨兵 先介绍一下比赛&#xf…

FFmpeg抽取视频h264数据重定向

根据视频重定向技术解析中的 截获解码视频流的思路&#xff0c;首先需要解决如何输出视频码流的问题。 目前只针对h264码流进行获取&#xff0c;步骤如下&#xff1a; 打开mp4文件并创建一个空文件用于存储H264数据 提取一路视频流资源 循环读取流中所有的包(AVPacket),为…

pytorch中的归一化:BatchNorm、LayerNorm 和 GroupNorm

1 归一化概述 训练深度神经网络是一项具有挑战性的任务。 多年来&#xff0c;研究人员提出了不同的方法来加速和稳定学习过程。 归一化是一种被证明在这方面非常有效的技术。 1.1 为什么要归一化 数据的归一化操作是数据处理的一项基础性工作&#xff0c;在一些实际问题中&am…

【c++随笔16】reserve之后,使用std::copy会崩溃?

【c随笔16】reserve之后&#xff0c;使用std::copy会崩溃? 一、reserve之后&#xff0c;使用std::copy会崩溃?二、函数std::reserve、std::resize、std::copy1、std::resize&#xff1a;2、std::reserve&#xff1a;3、std::copy&#xff1a; 三、崩溃原因分析方案1、你可以使…

【概率方法】重要性采样

从一个极简分布出发 假设我们有一个关于随机变量 X X X 的函数 f ( X ) f(X) f(X)&#xff0c;满足如下分布 p ( X ) p(X) p(X)0.90.1 f ( X ) f(X) f(X)0.10.9 如果我们要对 f ( X ) f(X) f(X) 的期望 E p [ f ( X ) ] \mathbb{E}_p[f(X)] Ep​[f(X)] 进行估计&#xff0…

极速学习SSM之SpringMVC笔记

文章目录 一、SpringMVC简介1、什么是MVC2、什么是SpringMVC3、SpringMVC的特点 二、HelloWorld1、开发环境2、创建maven工程a>添加web模块b>打包方式&#xff1a;warc>引入依赖 3、配置web.xmla>默认配置方式b>扩展配置方式 4、创建请求控制器5、创建springMVC…

ansible中的角色

1.理解roles在企业中的定位及写法 查看创建目录结构 ansible - galaxy list 指定新的位置 vim ansible.cfg roles_path ~/.ansible/roles 建立项目 cd roles/ ansible-galaxy init vsftpd tree vsftpd/ 编辑任务执行&#xff08;顺序&#xff09;文件 vim vsftpd/tas…

selenium库的使用

来都来了给我点个赞收藏一下再走呗&#x1f339;&#x1f339;&#x1f339;&#x1f339;&#x1f339; 目录 一、下载需要用到的python库selenium 二、selenium的基本使用 1.在python代码引入库 2.打开浏览器 3.元素定位 1&#xff09;通过id定位 2&#xff09;通过标…

Go开发运维:Go服务发布到K8S集群

目录 一、实验 1.Go服务发布到k8s集群 二、问题 1.如何从Harbor拉取镜像 一、实验 1.Go服务发布到k8s集群 &#xff08;1&#xff09;linux机器安装go(基于CentOS 7系统) yum install go -y &#xff08;2&#xff09;查看版本 go version &#xff08;3&#xff09;创…

UE引擎 LandscapeGrass 实现机制分析(UE5.2)

前言 随着电脑和手机硬件性能越来越高&#xff0c;游戏越来越追求大世界&#xff0c;而大世界非常核心的一环是植被&#xff0c;目前UE5引擎提供给植被生成的主流两种方式为 手刷植被和LandscapeGrass(WeightMap程序化植被)。当然UE5.3推出新一代PCGFramework 节点程序化生成框…

办公word-从不是第一页添加页码

总结 实际需要注意的是&#xff0c;分隔符、分节符和分页符并不是一个含义 分隔符包含其他两个&#xff1b;分页符&#xff1a;是增加一页&#xff1b;分节符&#xff1a;指将文档分为几部分。 从不是第一页插入页码1步骤 1&#xff0c;插入默认页码 自己可以测试时通过**…